How C# Handles Closures and Captured Variables
This article explores how the C# compiler internally manages closures and captured variables during compilation. When a lambda expression or anonymous method references a local variable from an outer scope, the runtime cannot simply use stack-allocated memory because the delegate may outlive the method that created it. To solve this lifetime mismatch, the C# compiler rewrites your code under the hood, hoisting captured variables into generated classes on the heap and transforming methods into instance members of those classes.
The Lifetime Problem
In C#, local variables exist on the execution stack and are destroyed
as soon as the enclosing method finishes executing. However, a delegate
(such as an Action or Func<T>) can be
stored in a field, passed around, or executed long after the enclosing
method has returned. If a delegate refers to a local stack variable that
no longer exists, memory corruption would occur. The compiler resolves
this by ensuring that any variable referenced by a closure lives on the
managed heap rather than the stack.
Compiler-Generated Display Classes
When the C# compiler detects a lambda expression or anonymous
function capturing a local variable, it generates a private, hidden
class—often named something like
<>c__DisplayClass0_0.
This generated class contains:
- Public fields corresponding to each captured local variable or parameter.
- An instance method containing the actual code from the lambda expression.
Consider the following C# code:
public Action CreateCounter()
{
int count = 0;
return () =>
{
count++;
Console.WriteLine(count);
};
}Behind the scenes, the compiler transforms the code into something resembling this:
public sealed class <>c__DisplayClass0_0
{
public int count;
public void <CreateCounter>b__0()
{
count++;
Console.WriteLine(count);
}
}
public Action CreateCounter()
{
<>c__DisplayClass0_0 locals = new <>c__DisplayClass0_0();
locals.count = 0;
return new Action(locals.<CreateCounter>b__0);
}The variable count is no longer a stack-allocated
integer; it is a field on a heap-allocated instance of the display
class.
Variable Hoisting and Scope Rewriting
The process of moving a variable from the stack into a generated class field is known as variable hoisting. Once a variable is hoisted, every reference to it inside the original method is rewritten to read from and write to the field of the generated class instance.
Because both the enclosing method and the lambda expression operate on the exact same object reference, mutations are visible to both scopes. Capturing is by reference, not by value.
Shared State Across Multiple Lambdas
If multiple lambda expressions within the same scope capture the same local variable, the compiler generates a single display class containing that variable. Both lambdas become instance methods on that shared class instance:
int value = 10;
Action add = () => value += 5;
Action print = () => Console.WriteLine(value);Both add and print hold a reference to the
same display class instance, allowing add to modify the
field and print to observe the updated value.
Conversely, if closures are defined in different scopes (such as inside separate iterations or nested code blocks), the compiler may generate multiple display classes or multiple instances of the same display class to reflect their distinct lifetimes.
Loop Variable Captures
A well-known consequence of this design occurs inside loops.
In a standard for loop:
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
}The loop index variable i is declared once outside the
loop body. The compiler instantiates a single display class instance
outside the loop. Every lambda captures that same field, so executing
all actions prints 3, 3, 3.
In modern C# (C# 5.0 and later), a foreach loop creates
a distinct logical variable per iteration. The compiler instantiates a
new display class instance on each loop pass, meaning each lambda
captures its own independent value.
Non-Capturing Optimization
If a lambda expression does not capture any local variables or
instance state (this), the compiler avoids allocating a new
object on each invocation. Instead, it emits a static method on a cached
compiler-generated class (often named <>c) and
assigns the delegate to a static singleton field. Subsequent calls reuse
this singleton delegate, incurring zero heap allocations per call.