Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilation.NETDeep Dive

Decompiling local functions in Glass.NET: name mangling and ref-struct closures

A local function is not a lambda, and the compiler proves it in the IL. There is no delegate allocation and no closure class — a non-capturing local function becomes a plain static method with a mangled name like <Run>g__Add|0_0, and a capturing one becomes a method that takes its captured variables by ref in a value-type display struct, so nothing lands on the heap. Here is exactly what the C# compiler emits for both kinds, how a decompiler that does not recognize the pattern hands you the mangled method and the ref struct, and how Glass.NET folds it back into the local function you wrote.

A local function looks like a lambda’s quieter sibling — a little method tucked inside another method — and developers often assume they compile to the same thing. They do not, and the difference is the entire reason local functions exist: no delegate, no heap-allocated closure. The C# compiler lowers a local function to a real method, and when that function captures a variable it passes the captured state by ref in a value-type struct, so nothing is allocated on the heap just to call a helper you wrote inline.

That lowering is distinctive enough that reading it is a good way to understand what the compiler is actually doing for you. This is a working deep-dive on exactly that: the mangled method names, the ref display struct, the call-site IL for both the capturing and non-capturing cases, and how Glass.NET recognizes the whole shape and folds it back into the local function you typed — instead of handing you a static method named <Sum>g__Add|0_0 and a struct you have to reverse-engineer.

The source

One method, two local functions. Add captures the enclosing total; Twice captures nothing and is marked static to say so.

public static class Demo
{
    public static int Sum(int[] xs)
    {
        int total = 0;

        void Add(int v) => total += v;       // captures `total`
        static int Twice(int v) => v * 2;    // captures nothing

        foreach (var x in xs)
            Add(Twice(x));

        return total;
    }
}

Two local functions that read almost identically in source compile to two very different shapes, because one touches an enclosing variable and the other does not.

What the compiler emits

Twice is the easy one. It captures nothing, so it becomes a plain private static method — the only unusual thing about it is the name, which the compiler mangles so you can never write it yourself:

.method assembly hidebysig static int32 '<Sum>g__Twice|0_1'(int32 v) cil managed
{
    .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
    ldarg.0
    ldc.i4.2
    mul
    ret
}

The name is <Sum>g__Twice|0_1: the enclosing method in angle brackets, g__, the original local-function name, a pipe, and an ordinal/counter pair that keeps it unique. The name between g__ and | is your name, Twice, preserved verbatim — which is precisely what lets a decompiler recover it.

Add is the interesting one. It captures total, so the compiler hoists total into a value-type display struct and gives Add a ref parameter to it:

.class nested private sealed sequential ansi '<>c__DisplayClass0_0'
    extends [mscorlib]System.ValueType       // a struct, not a class
{
    .field public int32 total
}

.method assembly hidebysig static void '<Sum>g__Add|0_0'(
    int32 v,
    valuetype Demo/'<>c__DisplayClass0_0'& 'display')   // passed by ref
{
    ldarg.1              // ref display
    ldarg.1
    ldfld int32 Demo/'<>c__DisplayClass0_0'::total
    ldarg.0              // v
    add
    stfld int32 Demo/'<>c__DisplayClass0_0'::total
    ret
}

This is the heart of it. A lambda that captured total would need a reference-type closure object, allocated on the heap, with total as a field, invoked through a Func/Action delegate. The local function needs none of that: the display type is a struct (extends System.ValueType), it lives on the caller’s stack, and Add receives it by ref so its writes to total are visible back in Sum. Zero allocation, no delegate.

The call site in Sum makes the ref passing explicit. The display struct is a local; calls take its address with ldloca and invoke the mangled methods directly with call — no newobj, no callvirt through a delegate:

.locals init (valuetype Demo/'<>c__DisplayClass0_0' display, ...)
ldloca.s   display
ldflda     int32 Demo/'<>c__DisplayClass0_0'::total   // &display.total = 0
...
// inside the loop, for Add(Twice(x)):
ldloca.s   x_current
call       int32 Demo::'<Sum>g__Twice|0_1'(int32)     // Twice(x) — plain static call
ldloca.s   display
call       void  Demo::'<Sum>g__Add|0_0'(int32, valuetype Demo/'<>c__DisplayClass0_0'&)
sourcevoid Add(int v) => total += v;captures totallowered ILstruct <>c__DisplayClass0_0 { int total; }<Sum>g__Add|0_0( int v, ref display)ldloca.s displaycall <Sum>g__Add|0_0no newobj · no delegate · stack-allocatedGlass reconstructionvoid Add(int v) => total += v;nested local function

What a naive decompiler shows

A decompiler that does not model the pattern is faithful to the metadata and therefore unreadable. It lists <Sum>g__Twice|0_1 and <Sum>g__Add|0_0 as sibling static methods of the class — not nested in Sum at all — and it surfaces the display struct as a real type with a ref parameter you are left to interpret:

// Naive output: structure lost, the capture mechanism exposed.
[StructLayout(LayoutKind.Auto)]
private struct <>c__DisplayClass0_0 { public int total; }

[CompilerGenerated]
private static void <Sum>g__Add|0_0(int v, ref <>c__DisplayClass0_0 display)
    => display.total += v;

[CompilerGenerated]
private static int <Sum>g__Twice|0_1(int v) => v * 2;

public static int Sum(int[] xs)
{
    <>c__DisplayClass0_0 display = default;
    display.total = 0;
    foreach (int x in xs)
        <Sum>g__Add|0_0(<Sum>g__Twice|0_1(x), ref display);
    return display.total;
}

Every bit of that is correct and none of it is what you wrote. The locality is gone — Add and Twice are no longer inside Sum — and the ref display plumbing that the compiler added for you is now something a reader has to decode.

What Glass reconstructs

Glass is built on ICSharpCode.Decompiler, the engine behind ILSpy, which recognizes the whole shape: the <Enclosing>g__Name|n_m name pattern, the [CompilerGenerated] marker, and the value-type display struct passed by ref. It reverses all three. The mangled methods are re-nested inside Sum as local functions named by the text between g__ and |; the display struct and its ref threading are dissolved back into a plain captured variable; and the static on Twice is restored because its signature shows no captured state:

// Glass output: the local functions you actually wrote.
public static int Sum(int[] xs)
{
    int total = 0;

    void Add(int v) => total += v;
    static int Twice(int v) => v * 2;

    foreach (int x in xs)
        Add(Twice(x));

    return total;
}

The difference from the lambda case is worth naming, because Glass shows it correctly too. Had Add been written as Action<int> add = v => total += v;, the reconstruction would show a delegate backed by a reference-type closure — a newobj of a display class, captured variables as its fields, and invocation through Action. Here there is no delegate anywhere, and Glass does not invent one: it shows a nested method and an ordinary captured local, which is the truth of the IL. Reading the two side by side in Glass is the clearest way to see, in your own code, why the guidance “prefer a local function to a lambda when you are not storing a delegate” is about allocations, not style.

When to drop to the IL view

The reconstruction is what you want most of the time, but a few things about local functions are only visible underneath it, and Glass lets you drop straight to the IL for the same member:

  • Was the capture by ref or by value? The display struct is passed by ref so writes propagate; confirming that in the IL (valuetype ...DisplayClass...&) is how you reason about whether a mutation inside the local function is seen by the caller.
  • Did the compiler elide the display struct entirely? For a genuinely non-capturing function you will see a bare static method with only its declared parameters and no struct — the proof that marking it static cost nothing and allocated nothing.
  • Ordinal collisions and overloads. The |n_m suffix disambiguates multiple local functions and multiple lexical scopes; the IL view is where you match a specific mangled method to a specific source position when a method has several.

Read the C# to recover intent and structure; drop to the IL when you need to prove to yourself exactly how the capture was wired. That the two views agree — a nested local function up top, a mangled static method and a ref struct underneath — is the whole point: Glass shows you the code you wrote without hiding the machinery that makes it cheap.

Try Nebula.NET

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