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:

  1. Public fields corresponding to each captured local variable or parameter.
  2. 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.