Variable Closure Capture in C# Lambda Expressions
Variable closure capture in C# occurs when a lambda expression or anonymous delegate references a variable defined outside its own scope. Instead of copying the variable's value at the moment the lambda is declared, the C# compiler captures the variable itself by reference, extending its lifetime. This article explains how closures work under the hood, highlights the differences between capturing by value versus reference, details the classic loop-variable pitfall, and demonstrates modern C# features to manage closures efficiently.
How Closures Work Under the Hood
When a lambda expression references an outer variable (known as an outer variable or free variable), the variable is said to be "captured." The C# compiler handles this behind the scenes by:
- Generating a hidden internal class (often called a display class or closure class).
- Moving the captured local variable into a field within that generated class.
- Rewriting the lambda expression as an instance method on that class.
Because the variable is moved to an instance field on a heap-allocated class, it is captured by reference, not by value. Any modifications made to the variable after the lambda definition are reflected when the lambda executes.
int factor = 2;
Func<int, int> multiplier = x => x * factor;
factor = 10;
Console.WriteLine(multiplier(3)); // Output: 30, not 6In the example above, factor is updated to
10 before the delegate is invoked. Because
multiplier references the field on the generated display
class, it uses the updated value 10.
The Loop Variable Pitfall
The reference-capturing behavior often causes unexpected results when lambdas are created inside loops.
Standard for Loops
In a standard for loop, the loop control variable is
declared once for the entire duration of the loop. If a lambda captures
the loop counter, all generated delegates point to the exact same
variable instance.
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach (var action in actions)
{
action();
}Output:
3
3
3
By the time the delegates are invoked, the loop has completed, and
i has reached 3. To fix this, introduce a
locally scoped variable inside the loop body:
for (int i = 0; i < 3; i++)
{
int copy = i; // New variable per iteration
actions.Add(() => Console.WriteLine(copy));
}foreach Loops
Starting in C# 5.0, the loop variable in a foreach
statement is automatically treated as scoped to each individual
iteration. Capturing a foreach variable does not require an
extra local copy, as each iteration yields a distinct variable
instance.
Memory and Performance Implications
While closures provide great flexibility, they introduce performance overhead:
- Heap Allocations: The generated closure class and delegate instance require allocation on the managed heap, increasing garbage collection (GC) pressure in high-throughput scenarios.
- Extended Variable Lifetime: Captured objects cannot be collected by the GC as long as the delegate that captured them remains in memory or referenced elsewhere. Capturing a large object indirectly keeps it alive longer than intended.
Preventing Accidental Captures with Static Lambdas
C# 9.0 introduced the static modifier for anonymous
functions and lambdas. Adding static prevents the lambda
from capturing any local variables or enclosing instance state. If you
accidentally reference an outer variable inside a static
lambda, the compiler raises an error.
int factor = 10;
// Compiler Error: A static local function or static lambda cannot capture 'factor'.
Func<int, int> multiplier = static x => x * factor;
// Valid: Uses only parameters or constants
Func<int, int> doubler = static x => x * 2;Using static lambdas ensures zero unexpected allocations
and prevents memory leaks caused by accidental variable retention.