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:

  1. Generating a hidden internal class (often called a display class or closure class).
  2. Moving the captured local variable into a field within that generated class.
  3. 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 6

In 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.