Skip to content
← All posts
· Delta1 Labs Decompilation.NETGuide

What Lambdas and Closures Decompile To: Display Classes and Captured Variables

Decompile a method that uses a lambda and you find a type named <>c__DisplayClass with your local variables as fields. Here is how the C# compiler turns closures into classes, why a capturing lambda allocates and a non-capturing one does not, and what the generated code tells you about a closure bug.

A decompiler is very good at putting a lambda back the way you wrote it. But drop one level — ask to see what the compiler actually generated — and lambdas reveal themselves as something more concrete: ordinary classes with your local variables turned into fields. Understanding that transformation explains two things every .NET developer eventually trips over: why some lambdas allocate and others are free, and why a loop full of lambdas can all end up seeing the same value.

The no-capture case: a cached singleton

Start with a lambda that captures nothing from its surroundings:

items.Where(x => x.IsActive);

x => x.IsActive depends only on its argument. It is the same delegate on every call, so the compiler generates a hidden singleton class — conventionally <>c — holds one cached instance, and stores the delegate in a static field. Decompiled, it looks like this:

[CompilerGenerated]
private sealed class <>c
{
    public static readonly <>c <>9 = new <>c();
    public static Func<Item, bool> <>9__0_0;   // the cached delegate

    internal bool <Run>b__0_0(Item x) => x.IsActive;
}
// call site:
items.Where(<>c.<>9__0_0 ??= new Func<Item, bool>(<>c.<>9.<Run>b__0_0));

The ??= is the point: the delegate is created once, cached, and reused forever. A non-capturing lambda in a hot path is effectively free after the first call — no per-call allocation. Seeing <>c in a decompile tells you immediately that this lambda captures nothing.

The capture case: a display class

Now capture a local:

public Func<int, int> MakeAdder(int delta)
{
    int calls = 0;
    return x => { calls++; return x + delta; };
}

The lambda uses delta and calls, both of which belong to MakeAdder”s stack frame — a frame that is gone the moment MakeAdder returns, while the returned delegate lives on. The compiler”s answer is to move those variables off the stack and onto the heap, inside a display class:

[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
    public int delta;   // was the parameter
    public int calls;   // was the local

    internal int <MakeAdder>b__0(int x) { calls++; return x + delta; }
}

public Func<int, int> MakeAdder(int delta)
{
    var cs = new <>c__DisplayClass0_0();   // one allocation per call
    cs.delta = delta;
    cs.calls = 0;
    return cs.<MakeAdder>b__0;              // delegate over the display-class method
}

Three things are now visible that the source hid. The captured variables became fields (delta, calls). The method MakeAdder was rewritten to read and write those fields instead of locals — so calls++ inside the lambda and any use of calls in the method are the same field, which is how a closure shares mutable state. And there is one allocation of the display class every time MakeAdder runs. That allocation is the real cost of a capturing lambda; in a tight loop it is the thing to notice.

MakeAdder stack frameint deltaint callscaptured<>c__DisplayClass0_0 (heap)public int delta;public int calls;int b__0(int x) { ... }

Reading a closure bug

Capture-as-fields is also where the most famous closure surprise lives. Consider building delegates in a loop:

var actions = new List<Action>();
for (int i = 0; i < 3; i++)
    actions.Add(() => Console.Write(i));
foreach (var a in actions) a();   // prints 333, not 012

Why 333? Because in a for loop the index i is one variable shared across all iterations. Decompiled, there is a single display class created once, all three lambdas capture the same i field, and by the time they run the loop has left i at 3. The generated code makes it unambiguous: one display-class instance, one i field, three delegates pointing at it.

Contrast a foreach over a collection in C# 5 and later, where the loop variable is fresh per iteration by language rule:

foreach (var item in items)
    actions.Add(() => Use(item));   // each lambda sees its own 'item'

Here the decompiler shows a new display class allocated inside the loop body, once per iteration, each holding that iteration”s item. Same syntax shape as the for loop, completely different generated code — and the decompiled form is the fastest way to see which one you have when a capture behaves unexpectedly. If you need per-iteration capture in a for loop, copy the index into a local declared inside the loop (int j = i;) and capture j; the decompile will then show a display class per iteration, exactly as the foreach does.

Why the generated view is worth a look

Most of the time you want the decompiler”s reconstructed lambda — it reads like your source and shows intent. Glass.NET gives you that, and lets you drop to the raw <>c__DisplayClass when a question is really about mechanics: Is this lambda allocating on a hot path (display class) or cached (<>c)? Do these loop lambdas share one captured variable or get a fresh one each time? Those are exactly the questions a profiler points you at and the reconstructed C# cannot answer — but the generated classes state them plainly. A lambda is syntax; a closure is a class, and once you can read the class, the runtime behaviour stops being surprising.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.