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

Anonyme Typen dekompilieren: die generierte generische Klasse hinter new { a, b }

Ein anonymer Typ hat keinen Namen, den Sie geschrieben haben, aber er ist eine echte Klasse in den Metadaten — eine generische, die der Compiler synthetisiert, benannt etwa <>f__AnonymousType0`2, mit einer readonly-Eigenschaft pro Member, einem Konstruktor, der sie alle entgegennimmt, und überschriebenen Equals, GetHashCode und ToString. Der Trick, der sie wiederverwendbar macht, ist, dass die generierte Klasse generisch über ihre Eigenschaftstypen ist, sodass zwei new { }-Stellen mit denselben Eigenschaftsnamen in derselben Reihenfolge einen Backing-Typ teilen, der mit verschiedenen Typargumenten instanziiert wird. Dieser Beitrag liest die generierte Klasse und die newobj-Aufrufstelle im IL, zeigt, wo die Eigenschaftsnamen tatsächlich leben (auf den Eigenschaften des generierten Typs, nicht auf den <Name>j__TPar-Typparametern), geht das strukturelle Equals und das Hash-kombinierende GetHashCode durch, die der Compiler schreibt, und erklärt, wie Glass.NET das Muster erkennt und zurück in new { Name = ..., Total = ... } faltet, statt Ihnen einen unaussprechlichen generischen Typ zu hinterlassen.

Tippen Sie ein new { } in C#, und Sie erzeugen ein Objekt mit einem Typ, den Sie nicht benennen können. var row = new { Name = customer.Name, Total = customer.Total }; liefert ein row, von dem Sie .Name und .Total lesen, das Sie an LINQ übergeben und auf Gleichheit vergleichen können — aber versuchen Sie, seinen Typ hinzuschreiben, und Sie können es nicht, weil es dafür kein Schlüsselwort gibt. Diese Abwesenheit ist eine Illusion der Quellsprache, genau wie ?. keinen IL-Opcode hat. In der kompilierten Assembly ist der anonyme Typ eine vollkommen gewöhnliche Klasse. Der Compiler hat sie nur synthetisiert, ihr einen Namen gegeben, den Sie nicht aussprechen dürfen, und sie neben Ihrem Code ausgegeben. Einen anonymen Typ gut zu dekompilieren bedeutet, diese synthetisierte Klasse als das zu erkennen, was sie ist, und sie zurück in das new { } zu falten, das sie erzeugt hat.

Die Klasse, die der Compiler schreibt

Für new { Name = ..., Total = ... } gibt der C#-Compiler eine sealed, generische Klasse aus — ein Typparameter pro Eigenschaft —, die wörtlich dekompiliert so aussieht:

[CompilerGenerated]
[DebuggerDisplay(@"\{ Name = {Name}, Total = {Total} }", Type = "<Anonymous Type>")]
internal sealed class <>f__AnonymousType0<<Name>j__TPar, <Total>j__TPar>
{
    private readonly <Name>j__TPar <Name>i__Field;
    private readonly <Total>j__TPar <Total>i__Field;

    public <Name>j__TPar Name  => <Name>i__Field;
    public <Total>j__TPar Total => <Total>i__Field;

    public <>f__AnonymousType0(<Name>j__TPar Name, <Total>j__TPar Total)
    {
        <Name>i__Field  = Name;
        <Total>i__Field = Total;
    }
    // Equals, GetHashCode, ToString overrides follow — see below.
}

Das Backtick-2 im echten Metadatennamen <>f__AnonymousType0\2bedeutet zwei Typparameter. Generisch zu sein ist der ganze Grund, warum der Typ wiederverwendbar ist: Der Compiler schließt ihn an jeder Aufrufstelle über konkrete Typargumente, sodass jedesnew { }` mit denselben Eigenschaftsnamen in derselben Reihenfolge diese eine generierte Klasse teilt.

var a = new { Name = "Acme", Total = 42 };     // <>f__AnonymousType0<string, int>
var b = new { Name = other,  Total = count };  // SAME type, same instantiation
var c = new { Name = "x",    Total = 1L };     // <>f__AnonymousType0<string, long> — same type, different args
var d = new { Total = 1, Name = "x" };         // DIFFERENT type — order of properties differs

Ändern Sie die Namen, ordnen Sie sie um oder fügen Sie eine Eigenschaft hinzu, und der Compiler generiert einen anderen Backing-Typ (<>f__AnonymousType1 und so weiter). Gleiche Namen, gleiche Reihenfolge — ein geteilter Typ, egal wie viele Literale Sie geschrieben haben.

new { Name=.., Total=.. } #1new { Name=.., Total=.. } #2new { Total=.., Name=.. }<>f__AnonymousType0`2Name, Total — geteilte Backing-Klasse<>f__AnonymousType1`2Total, Name — andere Reihenfolge, andere Klassegleiche Namen + Reihenfolge → eine Klasse, über verschiedene Typargumente geschlossen

Die Aufrufstelle

Ein anonymes Objekt zu erzeugen ist im IL nichts Exotisches — es ist ein newobj des konstruierten generischen Typs, mit den Eigenschaftswerten in Deklarationsreihenfolge auf den Stack gelegt:

// var row = new { Name = c.Name, Total = c.Total };
ldarg.0
callvirt   instance string Customer::get_Name()
ldarg.0
callvirt   instance int32  Customer::get_Total()
newobj     instance void class '<>f__AnonymousType0'<string, int32>::.ctor(!0, !1)
stloc.0

Der newobj-Operand trägt den geschlossenen generischen Typ und seine Konstruktorsignatur (!0, !1) — die beiden Typparameter, hier an string und int32 gebunden. Das ist die Signatur eines anonymen Typs am Verwendungspunkt: ein Konstruktoraufruf, der jedes readonly-Feld auf einmal füllt. Es gibt keinen Setter-Pfad, weil die Eigenschaften nur lesbar sind; ein anonymer Typ ist konstruktionsbedingt unveränderlich.

Wo die Namen tatsächlich leben

Der leichte Fehler ist anzunehmen, die Eigenschaftsnamen versteckten sich in den Typparameternamen <Name>j__TPar und <Total>j__TPar und würden deshalb in einem Release-Build verschwinden. Tun sie nicht. Diese Typparameternamen sind nur Hinweise; die maßgeblichen Namen sind die echten Property-Metadatenzeilen auf der generierten Klasse — get_Name, get_Total und die Eigenschaften, die sie exponieren. Ein Decompiler liest die Namen direkt von diesen Zeilen ab, weshalb new { Name = ..., Total = ... } mit intakten Membern rekonstruiert wird, selbst ohne PDB. Was ein PDB hinzugefügt hätte, ist der Name der lokalen Variablen, in der Sie die Instanz gespeichert haben (row), nicht die Form des anonymen Typs, die unabhängig davon vollständig in den Metadaten vorliegt.

Die drei Overrides

Anonyme Typen sind wertgleich, und der Compiler macht das mit drei Overrides real. Equals ist strukturell: Es castet das Argument auf denselben geschlossenen generischen Typ und vergleicht jedes Feld über EqualityComparer<T>.Default.

public override bool Equals(object value)
{
    var other = value as <>f__AnonymousType0<<Name>j__TPar, <Total>j__TPar>;
    return other != null
        && EqualityComparer<<Name>j__TPar>.Default.Equals(<Name>i__Field,  other.<Name>i__Field)
        && EqualityComparer<<Total>j__TPar>.Default.Equals(<Total>i__Field, other.<Total>i__Field);
}

GetHashCode ist eine Hash-Kombination fester Form: Es startet von einer pro Typ eingebrannten Seed-Konstante des Compilers und faltet den Hash jedes Feldes mit dem Multipliziere-mit-Primzahl-Muster ein, das Roslyn überall verwendet.

public override int GetHashCode()
{
    int hash = 339618440;                 // per-type seed baked in by the compiler
    hash = hash * -1521134295 + EqualityComparer<<Name>j__TPar>.Default.GetHashCode(<Name>i__Field);
    hash = hash * -1521134295 + EqualityComparer<<Total>j__TPar>.Default.GetHashCode(<Total>i__Field);
    return hash;
}

ToString baut { Name = ..., Total = ... } mit einem StringBuilder, was auch das [DebuggerDisplay] auf der Klasse widerspiegelt. Keines dieser drei ist Code, den Sie geschrieben haben; sie sind die Signatur des Compilers für „das ist ein anonymer Typ“, und zusammen sind sie das Meiste von dem, worauf ein Decompiler achtet.

Namenspräfix <>f__AnonymousType[CompilerGenerated] + [DebuggerDisplay]sealed generisch, Nur-Getter-Props über readonly-FelderKtor: ein Arg pro Prop, Deklarationsreihenfolgeüberschreibt Equals / GetHashCode / ToStringzur Projektion faltennew { Name = .., Total = .. }

Wie Glass es zurückfaltet

Kein einzelnes Signal ist schlüssig — eine generische sealed-Klasse ist gewöhnlich, und [CompilerGenerated] sitzt auf vielen synthetisierten Typen. Glass.NET gleicht das Bündel ab: das Namenspräfix <>f__AnonymousType, das Paar [CompilerGenerated] und [DebuggerDisplay], dessen Formatzeichenkette die Eigenschaften buchstäblich buchstabiert, die Nur-Getter-Eigenschaften über readonly-Feldern, den einzigen Konstruktor mit allen Argumenten in Deklarationsreihenfolge und die drei strukturellen Overrides in ihren exakten Compiler-Formen. Wenn sie übereinstimmen, unterdrückt Glass den synthetisierten Typ vollständig — Sie sehen <>f__AnonymousType0 nie in der Ausgabe — und schreibt jedes newobj davon als new { ... }-Projektion um, wobei es die Membernamen aus den Eigenschaften und die Werte aus den Konstruktorargumenten an der Aufrufstelle nimmt.

// What Glass renders from the newobj above — the synthesized type is gone.
var row = new { Name = c.Name, Total = c.Total };

Zwei Feinheiten sind erwähnenswert. Wenn der Initialisierungswert bereits mit dem Membernamen übereinstimmt — new { c.Name } statt new { Name = c.Name } —, leitet der Compiler den Eigenschaftsnamen aus dem Ausdruck ab, und Glass stellt die kürzere Form des Projektionsinitialisierers wieder her, statt Name = auszuschreiben. Und die LINQ-Abfragesyntax stützt sich für let-Klauseln und Joins über mehrere Quellen auf anonyme Typen, wobei der Compiler eine Kette verschachtelter anonymer Typen, sogenannte transparente Bezeichner, durchfädelt; Glass muss diese Verschachtelung entwirren, um das ursprüngliche let wiederherzustellen, statt die Mechanik an die Oberfläche zu bringen. In beiden Fällen ist das Prinzip dasselbe: Der anonyme Typ ist eine echte Klasse, die der Compiler geschrieben hat, vollständig in den Metadaten beschrieben, und die Aufgabe des Decompilers ist es, sie wieder unaussprechlich zu machen — Ihnen das new { } zurückzugeben, mit dem Sie begonnen haben, nicht die Maschinerie darunter.

Nebula.NET testen

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