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):0x0nicht variant,0x1kovariant (out),0x2kontravariant (in). Nur bei Schnittstellen- und Delegatparametern jemals ungleich null. - Die
SpecialConstraintMask:0x04Verweistyp-Einschränkung (class),0x08Werttyp- / Nicht-nullable-Einschränkung (struct),0x10Standardkonstruktor-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:
notnullwird über ein Attribut ausSystem.Runtime.CompilerServicesamGenericParamtransportiert, ohne dass ein Spezial-Einschränkungsflag gesetzt ist. Ein Leser, der nur dieSpecialConstraintMaskheranzieht, sieht nichts und gibt einen uneingeschränkten Parameter aus, wodurch die Einschränkung völlig verloren geht.unmanagedwird alsstruct-Flag (0x08) plus eineGenericParamConstraint-Zeile kodiert, deren Typ einen erforderlichen Modifizierer trägt — einmodreqvonSystem.Runtime.CompilerServices.IsUnmanagedAttribute— an einerSystem.ValueType-Einschränkung. Ein Leser, der nur dasstruct-Bit sieht, gibtwhere T : structaus, was eine strikt schwächere Einschränkung alsunmanagedist 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:
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:
- Die
VarianceMasklesen ausGenericParam.Flags. Kovariant wirdout, kontravariant wirdin, und das Schlüsselwort wird in der Deklaration platziert (IProducer<out T>), nicht in derwhere-Klausel. Varianz wird nur für Schnittstellen- und Delegatparameter gelesen, wo sie legal ist. - Die
SpecialConstraintMasklesen auf die Bitsclass,structundnew()— aber bevor man einem nacktenstruct-Bit traut, prüfen, ob dieValueType-Einschränkung dasIsUnmanagedAttribute-modreq trägt, in welchem Fall die Einschränkungunmanagedist, nichtstruct. Auch die Attribute desGenericParamauf dienotnull-Markierung prüfen. - Jede
GenericParamConstraint-Zeile lesen für den Parameter und jedenTypeDefOrRefzu einer Basisklasse oder Schnittstelle auflösen. Ein Klassentyp geht in die Basistyp-Gruppe; Schnittstellen gehen in die Schnittstellen-Gruppe. - In die feste C#-Reihenfolge sortieren und ausgeben: die Einschränkung
class/struct/unmanaged/notnullzuerst, dann der Basistyp, dann die Schnittstellen, dannnew()zuletzt. Leere Gruppen werden übersprungen, und ein Parameter ganz ohne Einschränkungen bekommt keinewhere-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.