-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Description
(Note: this proposal was briefly discussed in #98, the C# design notes for Jan 21, 2015. It has not been updated further based on the discussion that's already occurred on that thread.)
Background
Today, lambdas automatically capture any referenced state into a closure object. These captures happen by reference, in that the "local" variable that’s closed over is compiled as a field onto the "display class," with an instance of that class used both by the containing method and by the lambda defined in it.
// Original code
public static void ContainingMethod()
{
int i = 42;
Action a = () => {
Console.WriteLine(i);
};
}
// Approximate compiled equivalent
public static void ContainingMethod()
{
var class2 = new <>c__DisplayClass1();
class2.i = 42;
Action action = new Action(class2.<ContainingMethod>b__0);
}
private sealed class <>c__DisplayClass1
{
public int i;
public void <ContainingMethod>b__0()
{
Console.WriteLine(this.i);
}
}The ability to write such concise code and have the compiler generate all of the necessary boilerplate is a huge productivity win.
Problem
While this is a productivity win, it also hides some key aspects of how the mechanism works, in particular how the data makes its way into the lambda and where that data is stored, namely in an allocated object.
Solution: Explicitly Specifying What Gets Captured
When C++11 introduced lambda support, it explicitly disabled the implicit automatic capturing of any referenced state. Instead, developers are forced to state their intentions for what they want captured by using a "capture list." C# today behaves as if all captured state is captured by reference, and we would retain that by default, but we could also allow developers (optionally) to use capture lists to be more explicit about what they want captured. Using a capture list, the previously explored ContainingMethod example could be written:
public static void ContainingMethod()
{
int i = 42;
Action a = [i]() => {
Console.WriteLine(i);
};
}This states that the lambda captures the ‘i’ variable and nothing else. As such, this code is the exact equivalent of the previous example and will result in exactly the same code being generated by the compiler. However, now that we’ve specified a capture list, any attempt by the method to use a value not included in the capture list will be met with an error. This verification helps the developer not only better understand what state is being used, it also helps to enable compiler-verification that no allocations are involved in a closure. If a closure is instead written with an empty capture list, the developer can be assured that the lambda won’t capture any state, won’t require a display class, and thus won’t result in an allocation (in most situations, the compiler will then also be able to statically cache the delegate, so that the delegate will only be allocated once for the program rather than once per method call):
public static void ContainingMethod()
{
int i = 42;
Action a = []() => {
Console.WriteLine(i); // Error: can’t access non-captured ‘i'
};
}Additional Support: Capturing By Value
Today if a developer wants the equivalent of capturing by value instead of capturing by reference, they must first make a copy outside of the closure and reference that copy, e.g
public static void ContainingMethod()
{
int i = 42; // variable to be captured by value
...
int iCopy = i;
Action a = () => {
Console.WriteLine(iCopy);
};
}In this example, since the lambda closes over iCopy rather than i, it's effectively capturing a copy by reference, and thus has the same semantics as if capturing i by value. This, however, is verbose and error prone, in that a developer must ensure that iCopy is only used inside the lambda and not elsewhere, and in particular not inside of another lambda that might close over the same value. Instead, we could support assignment inside of a capture list:
public static void ContainingMethod()
{
int i = 42; // variable to be captured by value
...
Action a = [int iCopy = i]() => {
Console.WriteLine(iCopy);
};
}Now, only iCopy and not i can be used inside of the lambda, and iCopy is not available outside of the scope of the lambda.
// Compiled equivalent
public static void ContainingMethod()
{
int i = 42;
var class2 = new <>c__DisplayClass1();
class2.iCopy = i;
Action action = new Action(class2.<ContainingMethod>b__0);
}
private sealed class <>c__DisplayClass1
{
public int iCopy;
public void <ContainingMethod>b__0()
{
Console.WriteLine(this.iCopy);
}
}With the developer explicitly specifying what to capture and how to capture it, the effects of the lambda capture are made much clearer for both the developer writing the code and someone else reading the code, improving the ability to catch errors in code reviews.
Alternate Approach
Instead of or in addition to support for lambda capture lists, we should consider adding support for placing attributes on lambdas. This would allow for a wide-range of features, but in particular would allow for static analysis tools and diagnostics to separately implement features like what lambda capture lists are trying to enable.
For example, if the "[]" support for specifying an empty capture list is unavailable but a developer was able to apply attributes to lambdas, they could create a "NoClosure" attribute and an associated Roslyn diagnostic that would flag cases where a lambda annotated with [NoClosure] actually captured something:
public static void ContainingMethod()
{
int i = 42;
Action a = [NoClosure]() => { // diagnostic error: lambda isn't allowed to capture
Console.WriteLine(i);
};
}