One of the new language features in C# 7 is local functions. This feature lets you define functions within the scope of other functions. The main benefit of local functions is encapsulation and a secondary benefit is that they bring local variables into scope.
Although local functions appear simple, their interaction with other language features quickly leads to questions. I wondered how they capture local variables. This post follows along as I go about answering that question.
TL;DR
Captured local variables are passed by reference to local functions.
Background
Since the recent release of C# 7 alongside Visual Studio 2017, I've been wondering which new language feature I'll use first. That question has now been answered; it was local functions and it was a real doozy too.
I had an async method that instantiated numerous IDisposable objects in using statements, which then iterated over a list of objects and performed a series of steps on each in parallel. Here is a simplified function that captures the essence of that method:
private async Task startCalculations()
{
using(var calculator = new Calculator())
using(var formatter = new Formatter())
{
var tasks = new List<Task>();
foreach(var calculation in this.calculations)
{
tasks.Add(calculator.Evaluate(calculation).ContinueWith(t => formatter.Format(calculation).Wait()));
}
await Task.WhenAll(tasks.ToArray());
}
}
The chain of steps built up inside the foreach loop was much more complicated and I wanted to refactor it into an async method before modifying it. In C# 6 I would achieve that by creating something like this private method:
private async Task performCalculation(Calculator calculator, Formatter formatter, Calculation calculation)
{
await calculator.Evaluate(calculation);
await formatter.Format(calculation);
}
This seemed like a great place to use C# 7's new local functions feature.
Benefits
The primary benefit of local functions is encapsulation.
When you add a private method to a class, any method in that class could call the method. If you add a private method, should you protect its usage with argument checking? What if it's only used by one other method that has already checked those arguments? What if someone doesn't realize this method is intended for use by only that one other method and calls it from another method?
Local functions solve all that by stopping other class methods from calling the local function and by clearly indicating the intent of the local function code to other developers.
The secondary benefit of local functions is that they bring local variables into scope. This saves you from having to explicitly pass along many variables to the local function.
A tertiary benefit of local functions is that they may encourage better behaviour from iterators and async methods. It is easy to write iterators and async methods that throw errors later than expected. Local functions make it much less cumbersome to write these so they produce errors when they are called rather than when they are executed.
Behaviour
Local functions can be declared anywhere in their enclosing function, but their position determines what variables will be in scope.
Consider test1
, the local
function follows the for loop; therefore, loop variable i
is not in scope and must be passed in. Whereas in test2
, the local
function is inside the for loop and loop variable i
can be used directly.
private void test1()
{
for(int i = 0; i < 10; i++)
{
Console.Out.WriteLine(local(i));
}
int local(int i)
{
return i;
}
}
Output:
0
1
2
3
4
5
6
7
8
9
private void test2()
{
for(int i = 0; i < 10; i++)
{
Console.Out.WriteLine(local());
int local()
{
return i;
}
}
}
Output:
0
1
2
3
4
5
6
7
8
9
test1
and test2
perform as expected and print 0 through 9 in order. While these results are as expected, closures can behave counterintuitively: this was the case prior to C# 5 when foreach loops did not capture their variables. See foreach now captures variables for details on how foreach loops changed in C# 5.
Considering my usage of tasks and their delayed execution in startCalculations
, I wondered how local functions get access to local variables. To start my investigation, I used ILSpy to examine test2
and found that local functions are passed their variables by reference.
This means that in situations like startCalculations
you need to be wary of what variables are in scope. Based on this new knowledge, I devised two further tests, test3
and test4
, to prove what I had discovered.
private async Task test3()
{
List<Task> tasks = new List<Task>();
for(int i = 0; i < 10; i++)
{
tasks.Add(local());
async Task local()
{
await Task.Delay(2000);
Console.Out.WriteLine(i);
}
}
await Task.WhenAll(tasks.ToArray());
}
Output:
10
10
10
10
10
10
10
10
10
10
private async Task test4()
{
List<Task> tasks = new List<Task>();
for(int i = 0; i < 10; i++)
{
tasks.Add(local(i));
}
async Task local(int i)
{
await Task.Delay(2000);
Console.Out.WriteLine(i);
}
await Task.WhenAll(tasks.ToArray());
}
Output:
3
9
6
0
7
4
5
2
8
1
This is exactly what you would expect if captured local variables were passed by reference to the local function. However, without that knowledge, test3
's behaviour could surprise you.
test3
prints out 10, 10 times, because the i
in local
refers to i
by reference and 10 is the value of i
when WriteLine
is called in local
. Whereas test4
prints out 0 through 9 in a random order, because the i
in local
is bound when local
is called to a copy of the current value of i
. The output is in a random order because the tasks are resumed from the Task.Delay
in a random order.
The implication is that if startCalculations
is to use local functions, it must explicitly pass the calculation
variable and must not rely on it being in scope. However, it is fine for it to use calculator
and formatter
as these are not modified by the foreach loop.
private async Task startCalculations()
{
using(var calculator = new Calculator())
using(var formatter = new Formatter())
{
var tasks = new List<Task>();
foreach(var calculation in this.calculations)
{
tasks.Add(performCalculation(calculation));
}
async Task performCalculation(Calculation calculation)
{
await calculator.Evaluate(calculation);
await formatter.Format(calculation);
}
await Task.WhenAll(tasks.ToArray());
}
}
Take note, remember that local variables are passed by reference to local functions, and then you won't be surprised in your own use of local functions.