Skip to content
← Alle Beiträge
· Delta1 Labs DecompilationGlass

Generische Einschränkungen und Varianz dekompilieren: where-Klauseln und out/in aus den Metadaten rekonstruieren

Einschränkungen generischer Typparameter und Varianz sind, genau wie das Strukturlayout, Merkmale, die ein Dekompiler nicht aus der Form des Codes wiederherstellen kann: Sie leben vollständig in den Metadaten. Jeder generische Parameter trägt in der Tabelle GenericParam eine Varianzrichtung und Spezial-Einschränkungsflags, und die Tabelle GenericParamConstraint listet den Basistyp und die Schnittstellen auf, an die ein Parameter gebunden ist. Kovarianz (out) und Kontravarianz (in) sind Flag-Bits; die Einschränkungen class / struct / new() sind ebenfalls Bits; aber notnull und unmanaged werden indirekt kodiert, über ein modreq und ein Attribut statt über ein rohes Flag. Verlierst du die Varianz, ist eine Schnittstelle nicht mehr so zuweisbar wie zuvor; gibst du die Einschränkungen in der falschen Reihenfolge wieder aus, kompiliert die Ausgabe überhaupt nicht. Dieser Beitrag zeigt, wo die Daten liegen, wie Glass.NET die Tabellen GenericParam und GenericParamConstraint liest, wie es die Einschränkungen in die exakte von C# geforderte Reihenfolge sortiert, und ein durchgearbeitetes Beispiel mit einer kovarianten Schnittstelle und einer Methode mit mehreren Einschränkungen.

Die meisten C#-Merkmale, die ein Dekompiler wiederherstellt, sind Compiler-Zucker: Der Compiler hat ein foreach zu einem try/finally um einen Enumerator herab-übersetzt, oder einen Iterator zu einer Zustandsmaschine, und der Dekompiler erkennt die Form des erzeugten Codes und faltet sie zurück. Generische Einschränkungen und Varianz sind nicht von dieser Art. Es gibt keinen Code zu erkennen, denn eine Einschränkung ist nicht ausführbar. where T : class, IComparable<T>, new() und das out in IProducer<out T> kompilieren zu Metadaten — Zeilen in ein paar Nebentabellen und eine Handvoll Flag-Bits — und nichts in irgendeinem Methodenkörper erwähnt sie. Ein Dekompiler, der nur IL in Anweisungen rekonstruiert, gibt einen generischen Typ aus, dem die Varianz fehlt, dem die Einschränkungen fehlen, oder der sie in einer Reihenfolge trägt, die nicht kompiliert.

Das ist wichtig, weil Einschränkungen und Varianz Teil des öffentlichen Vertrags eines Typs sind. Die Einschränkungen entscheiden, was ein Aufrufer für T einsetzen darf; die Varianz entscheidet, welche Zuweisungen zwischen konstruierten Typen legal sind. Macht man es falsch, lehnt der regenerierte Typ entweder Code ab, der vorher funktionierte, oder — schlimmer — lockert stillschweigend eine Garantie, auf die sich Aufrufer verließen. Dieser Beitrag geht genau durch, wo die Daten in den Metadaten liegen, wie Glass sie zurückliest und wie es die Einschränkungen in die einzige Reihenfolge sortiert, die C# akzeptiert.

Varianz und Einschränkungen im Quellcode

Hier sind die drei Formen, die dieser Beitrag rekonstruiert. Zuerst eine kovariante Schnittstelle: T erscheint nur in Ausgabeposition, kann also als out markiert werden:

public interface IProducer<out T>
{
    T Produce();
}

Kovarianz ist das, was es erlaubt, einen IProducer<string> an Code zu übergeben, der einen IProducer<object> erwartet: Jeder string, den der Produzent liefert, ist ein object, also ist die Zuweisung sicher und der Compiler erlaubt sie. Das Spiegelbild ist die Kontravarianz: T erscheint nur in Eingabeposition, kann also als in markiert werden:

public interface ISink<in T>
{
    void Accept(T value);
}

Ein ISink<object> kann für einen ISink<string> einspringen, denn alles, was irgendein object akzeptiert, akzeptiert gewiss einen string. Schließlich eine Methode mit einem vollständigen Satz von Einschränkungen:

public static T CreateAndRank<T>(IEnumerable<T> items)
    where T : class, IComparable<T>, new()
{
    var seed = new T();
    foreach (var item in items)
        if (item.CompareTo(seed) < 0)
            seed = item;
    return seed;
}

T muss ein Verweistyp sein (class), muss mit sich selbst vergleichbar sein (IComparable<T>) und muss einen öffentlichen parameterlosen Konstruktor haben (new()). Alle drei Tatsachen sind in den Metadaten verzeichnet, in einer bestimmten, reihenfolgefreien Form, die der Dekompiler wieder in eine legale, geordnete where-Klausel verwandeln muss.

Wo die Daten tatsächlich liegen

Zwei Metadatentabellen und eine Bitmaske tragen alles.

Die Tabelle GenericParam hat eine Zeile pro Typparameter, sowohl für Typen als auch für Methoden. Jede Zeile speichert die nullbasierte Position des Parameters, seinen Namen, seinen Besitzer und eine Spalte Flags mit zwei unabhängigen Bitgruppen:

  • Die VarianceMask (zwei niederwertige Bits): 0x0 nicht variant, 0x1 kovariant (out), 0x2 kontravariant (in). Nur bei Schnittstellen- und Delegatparametern jemals ungleich null.
  • Die SpecialConstraintMask: 0x04 Verweistyp-Einschränkung (class), 0x08 Werttyp- / Nicht-nullable-Einschränkung (struct), 0x10 Standardkonstruktor-Einschränkung (new()).

Die Tabelle GenericParamConstraint hat eine Zeile pro Typ-Einschränkung, jede paart eine GenericParam-Zeile mit einem TypeDefOrRef — der Basisklasse oder Schnittstelle, an die der Parameter gebunden ist. where T : SomeBase, IComparable<T> erzeugt zwei Zeilen für denselben Parameter: eine zeigt auf SomeBase, eine auf IComparable<T>.

So dargestellt, wie ILDasm es zeigt, setzt die kovariante Schnittstelle +T direkt in den Klassenkopf:

.class interface public abstract auto ansi IProducer`1<+T>
{
    .method public hidebysig newslot abstract virtual
        instance !T Produce() cil managed
    {
    }
}

Das +T ist das kovariante Bit der VarianceMask; ein kontravarianter Parameter wird als -T dargestellt, ein nicht varianter als schlichtes T. Die 1 ist die Stelligkeit. Für die eingeschränkte Methode druckt ILDasm die Basistyp-Einschränkung in Klammern vor dem Parameternamen und die Spezial-Einschränkungen als Schlüsselwörter am Parameter:

.method public hidebysig static !!T CreateAndRank<class .ctor
    ([mscorlib]System.IComparable`1<!!T>) T>
    (class [mscorlib]System.Collections.Generic.IEnumerable`1<!!T> items)
    cil managed
{
}

Lies diesen Kopf sorgfältig: class und .ctor sind die Bits der SpecialConstraintMask (class + new()), das eingeklammerte ([mscorlib]System.IComparable1<!!T>)ist die einzigeGenericParamConstraint-Zeile, und T` ist der Parametername. Nichts davon erscheint in irgendeiner Anweisung — es sind rein deklarative Metadaten, die an die Signatur der Methode angehängt sind.

Die Feinheit von notnull und unmanaged

Die drei klassischen Spezial-Einschränkungen besitzen je ein Bit der SpecialConstraintMask. Die beiden modernen nicht, denn die Bitmaske wurde vor ihrer Existenz eingefroren, und sie werden indirekt kodiert:

  • notnull wird über ein Attribut aus System.Runtime.CompilerServices am GenericParam transportiert, ohne dass ein Spezial-Einschränkungsflag gesetzt ist. Ein Leser, der nur die SpecialConstraintMask heranzieht, sieht nichts und gibt einen uneingeschränkten Parameter aus, wodurch die Einschränkung völlig verloren geht.
  • unmanaged wird als struct-Flag (0x08) plus eine GenericParamConstraint-Zeile kodiert, deren Typ einen erforderlichen Modifizierer trägt — ein modreq von System.Runtime.CompilerServices.IsUnmanagedAttribute — an einer System.ValueType-Einschränkung. Ein Leser, der nur das struct-Bit sieht, gibt where T : struct aus, was eine strikt schwächere Einschränkung als unmanaged ist und stillschweigend verwaltete Werttypen durchlässt.

In IL sieht der unmanaged-Fall aus wie ein mit struct geflaggter Parameter mit einer ValueType-Einschränkung mit modreq:

.method ... static void Blit<valuetype .ctor
    (class [System.Runtime]System.ValueType modreq(
        [System.Runtime]System.Runtime.CompilerServices.IsUnmanagedAttribute)) T>
    (...) cil managed

Das Vorhandensein des modreq(...IsUnmanagedAttribute) ist der gesamte Unterschied zwischen unmanaged und struct. Glass prüft es ausdrücklich, ebenso das notnull-Attribut, sodass keine der beiden modernen Einschränkungen verloren geht oder aufgeweitet wird.

Was ein naiver Dekompiler produziert

Ein um Methodenkörper herum gebauter Dekompiler hat hier nichts, woran er sich halten kann, denn nichts davon steht in einem Methodenkörper. Zwei Fehlermodi herrschen vor, und beide sind lautlos.

Verlorene Varianz. Die Schnittstelle kommt invariant zurück:

// FALSCH — das out ist weg
public interface IProducer<T>
{
    T Produce();
}

Das kompiliert. Sein einziges Mitglied ist identisch. Aber es ist nicht mehr kovariant, also schlägt jede IProducer<string>-zu-IProducer<object>-Zuweisung, die Aufrufer gegen das Original schrieben, nun beim Kompilieren gegen die regenerierte Schnittstelle fehl. Nichts an IProducer selbst sieht falsch aus; der Bruch zeigt sich nur an den Zuweisungsstellen, häufig in anderen Assemblys.

Falsche Einschränkungsreihenfolge. Ein Dekompiler, der die Metadaten durchläuft und Schlüsselwörter anhängt, wie er auf sie stößt, gibt etwas aus wie:

// FALSCH — kompiliert nicht
public static T CreateAndRank<T>(IEnumerable<T> items)
    where T : IComparable<T>, class, new(), struct
{ ... }

C# lehnt das rundheraus ab. Die class-Einschränkung muss zuerst kommen, new() muss zuletzt kommen, class und struct schließen sich gegenseitig aus, und die Schnittstellen-Einschränkungen sitzen in der Mitte. Die Metadaten bewahren nichts von dieser Reihenfolge — die GenericParamConstraint-Zeilen stehen in beliebiger Reihenfolge und die Flag-Bits haben gar keine Reihenfolge — also muss der Dekompiler die korrekte Reihenfolge auferlegen, statt wiederzugeben, was er gelesen hat.

Die Abbildung unten zeigt die zwei Metadatenquellen, die den Einschränkungssortierer von Glass speisen, der jede Einschränkung nach Kategorie einordnet und die Gruppen in der festen C#-Reihenfolge ausgibt:

GenericParam.Flags (Parameter T) SpecialConstraint: class | .ctor VarianceMask: 0x0 (nicht variant) GenericParamConstraint-Zeilen T -> IComparable`1<!T> Glass Einschränkungssortierer Feste C#-Reihenfolge, die der Sortierer ausgibt class IComparable<T> new() class/struct zuerst, dann Basistyp, dann Schnittstellen, dann new() zuletzt. where T : class, IComparable<T>, new() Die Reihenfolge wird per Sprachregel auferlegt, nicht aus der Tabelle gelesen — die Zeilen tragen keine Reihenfolge.

Wie Glass es rekonstruiert

Glass behandelt generische Parameter als Metadatenangelegenheit erster Klasse, getrennt von der Methodendekompilierung. Für jeden Parameter — Typ oder Methode — durchläuft es eine explizite Pipeline:

  1. Die VarianceMask lesen aus GenericParam.Flags. Kovariant wird out, kontravariant wird in, und das Schlüsselwort wird in der Deklaration platziert (IProducer<out T>), nicht in der where-Klausel. Varianz wird nur für Schnittstellen- und Delegatparameter gelesen, wo sie legal ist.
  2. Die SpecialConstraintMask lesen auf die Bits class, struct und new() — aber bevor man einem nackten struct-Bit traut, prüfen, ob die ValueType-Einschränkung das IsUnmanagedAttribute-modreq trägt, in welchem Fall die Einschränkung unmanaged ist, nicht struct. Auch die Attribute des GenericParam auf die notnull-Markierung prüfen.
  3. Jede GenericParamConstraint-Zeile lesen für den Parameter und jeden TypeDefOrRef zu einer Basisklasse oder Schnittstelle auflösen. Ein Klassentyp geht in die Basistyp-Gruppe; Schnittstellen gehen in die Schnittstellen-Gruppe.
  4. In die feste C#-Reihenfolge sortieren und ausgeben: die Einschränkung class / struct / unmanaged / notnull zuerst, dann der Basistyp, dann die Schnittstellen, dann new() zuletzt. Leere Gruppen werden übersprungen, und ein Parameter ganz ohne Einschränkungen bekommt keine where-Klausel.

Das Ergebnis ist der Quellcode, mit dem du begonnen hast, vertrags-für-vertrags-äquivalent:

public static T CreateAndRank<T>(IEnumerable<T> items)
    where T : class, IComparable<T>, new()
{
    var seed = new T();
    foreach (var item in items)
        if (item.CompareTo(seed) < 0)
            seed = item;
    return seed;
}

Weil die Schlüsselwörter class und new() aus der SpecialConstraintMask kamen und IComparable<T> aus der GenericParamConstraint-Zeile, und weil der Sortierer sie in die von C# geforderte Reihenfolge brachte, kompiliert die Methode neu mit genau den Einschränkungen, die das Original deklarierte — nicht stärker, nicht schwächer, und in einer Reihenfolge, die der Compiler akzeptiert.

Eine kovariante Schnittstelle, hin und zurück

Die Rekonstruktion der Schnittstelle ist kürzer, zeigt aber den Varianzpfad von Anfang bis Ende. Glass liest das kovariante +T-Bit aus der einzigen GenericParam-Zeile und gibt das out wieder aus:

public interface IProducer<out T>
{
    T Produce();
}

Das out ist wieder in der Deklaration, also ist IProducer<string> erneut an IProducer<object> zuweisbar. Hätte Glass nur die Mitgliedssignaturen gelesen und die VarianceMask übersprungen, wäre die Schnittstelle invariant zurückgekommen und jede variante Zuweisung weiter unten wäre gebrochen — die Art von Verlust, die im Typ selbst unsichtbar ist und nur an den Aufrufstellen auftaucht.

Ausführen

Richte Glass auf die Assembly und frage nach dem einen Typ. Beachte die Backtick-Stelligkeit im Typnamen:

glass decompile MyApp.dll --type MyApp.Abstractions.IProducer`1

Die Ausgabe ist die oben mit out annotierte Schnittstelle. Um den Typ zu dekompilieren, der die eingeschränkte Methode deklariert, und zu bestätigen, dass die Einschränkungen hin und zurück erhalten bleiben:

glass decompile MyApp.dll --type MyApp.Ranking.Ranker --members

Um einen ganzen Namensraum zu durchlaufen und zu prüfen, dass Varianz und Einschränkungen jedes generischen Parameters die Rekonstruktion überleben:

glass decompile MyApp.dll --namespace MyApp.Abstractions --constraint-check

--constraint-check liest die Tabellen GenericParam und GenericParamConstraint nach der Rekonstruktion erneut und prüft, dass die ausgegebenen Varianz-Schlüsselwörter, Spezial-Einschränkungen und Typbindungen der where-Klausel exakt mit den Metadaten übereinstimmen — sodass du eine Garantie bekommst, keine Hoffnung, dass die regenerierten Signaturen denselben Vertrag tragen wie das Original. Glass ist ein freier, offener Dekompiler; dies ist Standardausgabe, nichts Eingeschränktes.

Warum es sich lohnt, das ganz genau richtig zu machen

Varianz und Einschränkungen sind der Teil eines generischen Typs, den dessen Benutzer sehen. Ein verlorenes out ändert nicht die Mitglieder von IProducer, aber es widerruft stillschweigend ein Zuweisbarkeitsversprechen, auf dem anderer Code aufgebaut war. Eine falsch geordnete where-Klausel ändert nicht die Bedeutung von T, aber sie hindert die Datei am Kompilieren. Und ein struct, das dort ausgegeben wird, wo die Metadaten unmanaged sagten, lockert eine Garantie ohne eine einzige Warnung, denn die schwächere Einschränkung akzeptiert weiterhin alles, was die strengere akzeptierte, und mehr.

Die Regel, der Glass folgt, ist also dieselbe, die das Strukturlayout regiert: Dies sind Metadaten, Metadaten sind die maßgebliche Wahrheit, und die Rekonstruktion liest sie, statt zu raten. Die VarianceMask lesen, die SpecialConstraintMask lesen (und das modreq und das Attribut, die sich hinter einem nackten struct-Bit verbergen), jede GenericParamConstraint-Zeile lesen und das Ganze in der einzigen Reihenfolge wieder ausgeben, die C# akzeptiert. Wenn du generischen Code untersuchst oder debuggst, den du lesen darfst, ist diese Treue der Unterschied zwischen einer Signatur, der du vertrauen kannst, und einer, die den Vertrag stillschweigend ändert.

Nebula.NET testen

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