Skip to content
← Alle Beiträge
· Delta1 Labs Glass.NETDekompilierung.NETTiefenanalyse

Lokale Funktionen in Glass.NET dekompilieren: Name-Mangling und ref-struct-Closures

Eine lokale Funktion ist keine Lambda, und der Compiler beweist es im IL. Es gibt keine Delegate-Allokation und keine Closure-Klasse — eine lokale Funktion ohne Capture wird zu einer gewöhnlichen statischen Methode mit einem gemangelten Namen wie <Run>g__Add|0_0, und eine, die captured, wird zu einer Methode, die ihre erfassten Variablen per ref in einem Werttyp-Display-Struct empfängt, sodass nichts auf dem Heap landet. Hier ist genau, was der C#-Compiler für beide Arten ausgibt, wie ein Dekompiler, der das Muster nicht erkennt, dir die gemangelte Methode und das ref-Struct übergibt, und wie Glass.NET es in die lokale Funktion zurückfaltet, die du geschrieben hast.

Eine lokale Funktion sieht aus wie die stillere Schwester einer Lambda — eine kleine Methode, eingebettet in eine andere Methode — und oft wird angenommen, dass sie zum selben kompilieren. Das tun sie nicht, und der Unterschied ist der ganze Daseinsgrund lokaler Funktionen: kein Delegate, keine heap-allozierte Closure. Der C#-Compiler senkt eine lokale Funktion zu einer echten Methode ab, und wenn diese Funktion eine Variable captured, übergibt er den erfassten Zustand per ref in einem Werttyp-Struct, sodass nichts auf dem Heap alloziert wird, nur um einen Helfer aufzurufen, den du inline geschrieben hast.

Diese Absenkung ist unverwechselbar genug, dass ihr Lesen eine gute Möglichkeit ist zu verstehen, was der Compiler tatsächlich für dich tut. Dies ist eine praktische Tiefenanalyse genau davon: die gemangelten Methodennamen, das ref-Display-Struct, das Aufrufort-IL für den capturenden und den nicht capturenden Fall, und wie Glass.NET die gesamte Form erkennt und in die lokale Funktion zurückfaltet, mit der du begonnen hast — statt dir eine statische Methode namens <Sum>g__Add|0_0 und ein Struct zu übergeben, das du selbst rückentwickeln musst.

Der Quelltext

Eine Methode, zwei lokale Funktionen. Add captured das umgebende total; Twice captured nichts und ist static markiert, um das zu sagen.

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;
    }
}

Zwei lokale Funktionen, die sich im Quelltext fast identisch lesen, kompilieren zu sehr unterschiedlichen Formen, weil die eine eine umgebende Variable berührt und die andere nicht.

Was der Compiler ausgibt

Twice ist die einfache. Sie captured nichts, also wird sie zu einer schlichten privaten statischen Methode — das einzig Ungewöhnliche an ihr ist der Name, den der Compiler mangelt, damit du ihn nie selbst schreiben kannst:

.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
}

Der Name ist <Sum>g__Twice|0_1: die umgebende Methode in spitzen Klammern, g__, der ursprüngliche Name der lokalen Funktion, ein senkrechter Strich und ein Ordinal/Zähler-Paar, das ihn eindeutig hält. Der Name zwischen g__ und | ist dein Name, Twice, wortwörtlich bewahrt — was genau das ist, was einem Dekompiler erlaubt, ihn wiederherzustellen.

Add ist die interessante. Sie captured total, also hievt der Compiler total in ein Werttyp-Display-Struct und gibt Add einen ref-Parameter darauf:

.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
}

Das ist der Kern. Eine Lambda, die total captured, bräuchte ein Referenztyp-Closure-Objekt, auf dem Heap alloziert, mit total als Feld, aufgerufen über ein Func/Action-Delegate. Die lokale Funktion braucht nichts davon: Der Display-Typ ist ein struct (extends System.ValueType), er lebt auf dem Stack des Aufrufers, und Add empfängt ihn per ref, damit seine Schreibzugriffe auf total zurück in Sum sichtbar sind. Null Allokation, kein Delegate.

Der Aufrufort in Sum macht die ref-Übergabe explizit. Das Display-Struct ist eine lokale Variable; Aufrufe nehmen ihre Adresse mit ldloca und rufen die gemangelten Methoden direkt mit call auf — kein newobj, kein callvirt über ein 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'&)
Quellevoid Add(int v) => total += v;captured totalabgesenktes ILstruct <>c__DisplayClass0_0 { int total; }<Sum>g__Add|0_0( int v, ref display)ldloca.s displaycall <Sum>g__Add|0_0kein newobj · kein Delegate · auf dem StackGlass-Rekonstruktionvoid Add(int v) => total += v;verschachtelte lokale Funktion

Was ein naiver Dekompiler zeigt

Ein Dekompiler, der das Muster nicht modelliert, ist den Metadaten treu und daher unlesbar. Er listet <Sum>g__Twice|0_1 und <Sum>g__Add|0_0 als Geschwister-statische-Methoden der Klasse — gar nicht in Sum verschachtelt — und bringt das Display-Struct als echten Typ mit einem ref-Parameter an die Oberfläche, den du interpretieren musst:

// 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;
}

Alles davon ist korrekt und nichts davon ist, was du geschrieben hast. Die Lokalität ist weg — Add und Twice sind nicht mehr innerhalb von Sum — und die ref display-Verrohrung, die der Compiler für dich hinzugefügt hat, ist jetzt etwas, das ein Leser entschlüsseln muss.

Was Glass rekonstruiert

Glass basiert auf ICSharpCode.Decompiler, der Engine hinter ILSpy, die die gesamte Form erkennt: das Namensmuster <Umgebend>g__Name|n_m, die Markierung [CompilerGenerated] und das per ref übergebene Werttyp-Display-Struct. Es kehrt alle drei um. Die gemangelten Methoden werden wieder in Sum als lokale Funktionen verschachtelt, benannt nach dem Text zwischen g__ und |; das Display-Struct und seine ref-Fädelung lösen sich in eine schlichte erfasste Variable auf; und das static an Twice wird wiederhergestellt, weil seine Signatur keinen erfassten Zustand zeigt:

// 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;
}

Es lohnt sich, den Unterschied zum Lambda-Fall zu benennen, denn Glass zeigt auch ihn korrekt. Wäre Add als Action<int> add = v => total += v; geschrieben worden, würde die Rekonstruktion ein Delegate zeigen, gestützt auf eine Referenztyp-Closure — ein newobj einer Display-Klasse, erfasste Variablen als ihre Felder und Aufruf über Action. Hier gibt es nirgends ein Delegate, und Glass erfindet keines: Es zeigt eine verschachtelte Methode und eine gewöhnliche erfasste lokale Variable, was die Wahrheit des IL ist. Die beiden in Glass nebeneinander zu lesen ist die klarste Art, in deinem eigenen Code zu sehen, warum der Rat „bevorzuge eine lokale Funktion gegenüber einer Lambda, wenn du kein Delegate speicherst” von Allokationen handelt, nicht von Stil.

Wann man zur IL-Ansicht hinabsteigt

Die Rekonstruktion ist meistens, was du willst, aber einige Dinge über lokale Funktionen sind nur darunter sichtbar, und Glass lässt dich für denselben Member direkt zum IL hinabsteigen:

  • War die Capture per ref oder per Wert? Das Display-Struct wird per ref übergeben, damit Schreibzugriffe propagieren; das im IL zu bestätigen (valuetype ...DisplayClass...&) ist die Art, darüber nachzudenken, ob eine Mutation innerhalb der lokalen Funktion vom Aufrufer gesehen wird.
  • Hat der Compiler das Display-Struct ganz weggelassen? Für eine wirklich capture-freie Funktion siehst du eine nackte statische Methode mit nur ihren deklarierten Parametern und keinem Struct — der Beweis, dass es nichts kostete und nichts allozierte, sie static zu markieren.
  • Ordinal-Kollisionen und Überladungen. Das Suffix |n_m disambiguiert mehrere lokale Funktionen und mehrere lexikalische Gültigkeitsbereiche; die IL-Ansicht ist, wo du eine bestimmte gemangelte Methode einer bestimmten Quellposition zuordnest, wenn eine Methode mehrere hat.

Lies das C#, um Absicht und Struktur wiederzugewinnen; steig zum IL hinab, wenn du dir selbst beweisen musst, wie genau die Capture verdrahtet wurde. Dass die beiden Ansichten übereinstimmen — eine verschachtelte lokale Funktion oben, eine gemangelte statische Methode und ein ref-Struct darunter — ist der ganze Sinn: Glass zeigt dir den Code, den du geschrieben hast, ohne die Maschinerie zu verbergen, die ihn billig macht.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.