DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

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

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);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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; };
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)