Nullable-Werttypen dekompilieren: int? ist ein Struct, ein angehobener Operator ist eine HasValue-Prüfung, und das Boxing, das Nullable<T> auslöscht
int? sieht aus wie ein Typ mit einem null darin, und in der IL ist es keines von beidem: es ist das generische Struct System.Nullable<int>, zwei Felder und nirgendwo ein null, und jede Operation, die die Sprache Sie darauf schreiben lässt, wird vom Compiler in Aufrufe auf diesem Struct umgeschrieben. a + b auf zwei int?-Werten wird zu einer HasValue-Konjunktion, zwei GetValueOrDefault-Aufrufen, einem add und einem newobj — oder einem initobj für das null-Ergebnis. a == b wird zu einem Wertvergleich, der per UND mit einem HasValue-Vergleich verknüpft ist. a == null wird zu einem einzigen HasValue-Aufruf. a ?? 5 wird zu einer Bedingung über HasValue. Und das Boxen eines int? tut etwas, das kein anderes Struct tut: die box-Anweisung liefert entweder ein geboxtes int oder eine null-Referenz, sodass Nullable<T> auf dem Heap nie existiert. Dieser Beitrag liest jede dieser Absenkungen in IL und folgt einem Decompiler dabei, sie zurück in die Operatoren zu falten, die der Autor geschrieben hat — warum das HasValue-und-GetValueOrDefault-Muster als Einheit erkannt werden muss, warum int? aus dem Signatur-Blob kommt und nicht aus einem Attribut (anders als string?), wie unbox.any auf einer null-Referenz stillschweigend ein leeres Nullable erzeugt, und was ein Decompiler falsch macht, wenn er die Absenkung statt der Sprache wiedergibt.
Alles, was die Sprache C# Sie mit int? tun lässt, ist eine Lüge, die der Compiler im Namen eines Structs erzählt. In einem int? gibt es kein null. Es gibt keinen speziellen „nullable Integer”-Typ, den die CLR addieren könnte. Es gibt System.Nullable<int> — zwei Felder, sechzehn Bytes, ein Werttyp wie jeder andere — und einen Compiler, der a + b, a == null, a ?? 5 und (object)a so gründlich in Aufrufe auf diesem Struct umschreibt, dass die Operatoren, die Sie getippt haben, die IL nie erreichen. Ein obfuskiertes oder symbolloses Assembly zu lesen, das Nullable-Arithmetik verwendet, bedeutet, diese Umschreibungen zu lesen, und ein Decompiler, der sie wörtlich wiedergibt, erzeugt Code, der korrekt, kompilierbar und unlesbar ist.
Dieser Beitrag folgt jeder Absenkung durch die IL und wieder heraus und zeigt, was Glass.NET erkennen muss, um Ihnen die Zeile zurückzugeben, die Sie geschrieben haben.
int? steckt im Signatur-Blob, nicht in einem Attribut
Zuerst der einfache Teil, denn er ist das Gegenteil einer Falle, die Leser von Nullable-Referenztypen zu erwarten gelernt haben. string? lässt den Typ unangetastet und hält die Nullability in [Nullable]-Attributen fest — Kompilierzeit-Annotationen, die die Laufzeit ignoriert. int? ist ein anderer Typ: das generische Struct Nullable<T>, instanziiert über int, und es erscheint als solches in jeder Signatur:
.method public hidebysig static valuetype [System.Runtime]System.Nullable`1<int32>
Add(valuetype [System.Runtime]System.Nullable`1<int32> a,
valuetype [System.Runtime]System.Nullable`1<int32> b) cil managed
Ein Decompiler gibt int? für valuetype System.Nullable`1<int32> nach derselben Regel aus, nach der er int für System.Int32 ausgibt — die Kurzform der Sprache für einen bekannten Typ. Kein Attribut wird konsultiert; nichts kann entfernt werden, das es verlieren würde. Metadaten-Härtung, die [Nullable]-Annotationen entfernt, lässt int? völlig intakt, weil die Information nie eine Annotation war.
Das Struct selbst besteht aus zwei Feldern und einer Handvoll Member, und diese Member sind das gesamte Vokabular jeder folgenden Absenkung:
public struct Nullable<T> where T : struct
{
private readonly bool hasValue;
internal T value;
public Nullable(T value); // the "has a value" constructor
public bool HasValue { get; }
public T Value { get; } // throws InvalidOperationException when !hasValue
public T GetValueOrDefault(); // returns value unconditionally — no check, no throw
}
default(Nullable<T>) — beide Felder null — ist das, was die Sprache null nennt.
Angehobene Arithmetik: eine Konjunktion, zwei GetValueOrDefault und ein newobj
Nehmen Sie die Methode, deren Signatur oben erscheint:
static int? Add(int? a, int? b) => a + b;
Das + auf zwei int?-Werten ist ein angehobener Operator: das Ergebnis ist null, wenn einer der Operanden null ist, andernfalls die Summe. Der Compiler gibt diese Regel direkt aus:
.locals init (valuetype Nullable`1<int32> V_0,
valuetype Nullable`1<int32> V_1,
valuetype Nullable`1<int32> V_2)
IL_0000: ldarg.0
IL_0001: stloc.0 // copy a to a temp (operands evaluated once)
IL_0002: ldarg.1
IL_0003: stloc.1 // copy b
IL_0004: ldloca.s V_0
IL_0006: call instance bool Nullable`1<int32>::get_HasValue()
IL_000b: ldloca.s V_1
IL_000d: call instance bool Nullable`1<int32>::get_HasValue()
IL_0012: and // both have a value?
IL_0013: brtrue.s IL_0020
IL_0015: ldloca.s V_2
IL_0017: initobj Nullable`1<int32> // the null result: default(Nullable<int>)
IL_001d: ldloc.2
IL_001e: ret
IL_0020: ldloca.s V_0
IL_0022: call instance !0 Nullable`1<int32>::GetValueOrDefault()
IL_0027: ldloca.s V_1
IL_0029: call instance !0 Nullable`1<int32>::GetValueOrDefault()
IL_002e: add
IL_002f: newobj instance void Nullable`1<int32>::.ctor(!0)
IL_0034: ret
Drei Details sind beachtenswert, denn sie sind das, worauf ein Decompiler achtet:
- Die Operanden werden zuerst in Temporäre kopiert, sodass jeder genau einmal ausgewertet wird, obwohl er zweimal gelesen wird (einmal für
HasValue, einmal für den Wert). Eine handgeschriebene Version würde sich das meist sparen. - Der Wert wird mit
GetValueOrDefaultgelesen, nicht mitValue.HasValuewurde gerade geprüft, sodass die erneute Prüfung inValueredundant und ihr Wurfpfad unerreichbar wäre;GetValueOrDefaultist ein schlichtes Feldlesen.GetValueOrDefaultunmittelbar nach einerHasValue-Absicherung zu sehen, ist das stärkste einzelne Signal, dass Sie Compiler-Ausgabe und nicht Quellcode betrachten. - Das null-Ergebnis ist
initobj, kein Aufruf — es gibt keinen „null-Konstruktor”; der null-Zustand ist das genullte Struct.
Geben Sie das wörtlich wieder, und Sie erhalten:
// faithful to the IL, and not what anyone wrote:
int? num = a, num2 = b;
return (num.HasValue & num2.HasValue)
? new int?(num.GetValueOrDefault() + num2.GetValueOrDefault())
: null;
Das kompiliert und ist semantisch exakt. Es sind auch drei Zeilen, zwei Temporäre und vier Aufrufe für ein einziges +. Glass erkennt die ganze Form — Temporäre, Konjunktion von HasValue, ein Paar GetValueOrDefault, das einen binären Operator speist, newobj auf dem wahren Zweig und initobj auf dem falschen — und gibt a + b aus. Dieselbe Vorlage deckt jeden angehobenen arithmetischen, bitweisen und Schiebeoperator ab; nur die Anweisung zwischen den beiden GetValueOrDefault-Aufrufen ändert sich.
Angehobene Gleichheit ist eine andere Form
Der Vergleich wird nicht auf dieselbe Weise abgesenkt, weil die Regel anders ist: zwei nulls sind gleich, und ein null ist ungleich zu jedem Wert. Der Compiler erzeugt dafür einen sprungfreien Ausdruck:
static bool Same(int? a, int? b) => a == b;
IL_0004: ldloca.s V_0
IL_0006: call instance !0 Nullable`1<int32>::GetValueOrDefault()
IL_000b: ldloca.s V_1
IL_000d: call instance !0 Nullable`1<int32>::GetValueOrDefault()
IL_0012: ceq // values equal (both 0 when both null)
IL_0014: ldloca.s V_0
IL_0016: call instance bool Nullable`1<int32>::get_HasValue()
IL_001b: ldloca.s V_1
IL_001d: call instance bool Nullable`1<int32>::get_HasValue()
IL_0022: ceq // same has-value state
IL_0024: and
IL_0025: ret
Das ist (a.GetValueOrDefault() == b.GetValueOrDefault()) & (a.HasValue == b.HasValue). Gehen Sie die Fälle durch: beide null → 0 == 0 und false == false → wahr. Eines null, eines 5 → 0 == 5 ist falsch → falsch. Beide 5 → wahr. Beachten Sie, dass GetValueOrDefault hier ohne vorherige HasValue-Absicherung aufgerufen wird — es wird gerade deshalb verwendet, weil es auf einem null sicher ist — sodass das Signal „GetValueOrDefault nach Absicherung” aus dem arithmetischen Fall nicht gilt und der Decompiler eine zweite Vorlage für diese Form braucht. <, >, <=, >= werden zum Wertvergleich abgesenkt, per UND verknüpft mit a.HasValue & b.HasValue (zwei nulls sind nicht kleiner als einander), was eine dritte Variante derselben Idee ist.
== null, ?? und das Muster is int v
Die einfacheren Formen haben einfachere Absenkungen, jede davon eine Übereinstimmung mit einer einzigen Vorlage:
bool IsNull(int? a) => a == null; // ldarga a; call get_HasValue; ldc.i4.0; ceq
int OrFive(int? a) => a ?? 5; // a.HasValue ? a.GetValueOrDefault() : 5
int Force (int? a) => (int)a; // ldarga a; call get_Value — the only place Value appears
bool Test (int? a) => a is int v && v > 0; // a.HasValue && a.GetValueOrDefault() > 0
== null vergleicht nie eine Referenz: es gibt keine Referenz. Es ist !a.HasValue, und != null ist a.HasValue. ?? wird zu einer Bedingung über HasValue mit GetValueOrDefault auf dem Wertzweig — wieder nicht Value, weil die Absicherung bereits gelaufen ist. Die explizite Konvertierung (int)a ist die einzige Absenkung, die get_Value aufruft, weil sie die einzige ist, deren Vertrag es ist, zu werfen, wenn nichts da ist; ein Decompiler kann das als Indiz nutzen. Und das Typmuster a is int v über einem Nullable wird zu HasValue plus GetValueOrDefault nach v abgesenkt, ohne irgendein isinst — der Laufzeit-Typtest, nach dem das Muster aussieht, findet nie statt, weil der Compiler bereits weiß, was das Struct enthält.
Das Boxing, das Nullable<T> auslöscht
Alles bisher ist der Compiler, der Sprache in Aufrufe umschreibt. Boxing ist anders: hier tut die Laufzeit etwas Besonderes, und die IL sagt weniger, als es scheint.
object Box(int? a) => a; // box valuetype Nullable`1<int32>
int? Unbox(object o) => (int?)o; // unbox.any valuetype Nullable`1<int32>
Das Boxen eines gewöhnlichen Structs kopiert es hinter einem Objekt-Header auf den Heap. Die box-Anweisung, angewandt auf Nullable<T>, tut das nicht. Die Laufzeit prüft hasValue: ist es false, ist das Ergebnis eine null-Referenz; ist es true, ist das Ergebnis ein geboxtes T — ein Heap-Objekt vom Typ System.Int32, Byte für Byte identisch mit dem Boxen eines gewöhnlichen int. Nullable<int> selbst erreicht den Heap nie. Die Folgen sind von C# aus sichtbar:
object o = (int?)5;
o.GetType() // typeof(int) — not Nullable<int>
o is int // true
o is int? // true — same object satisfies both
object n = (int?)null; // n == null; there is no "boxed empty nullable"
unbox.any Nullable<int32> ist das Spiegelbild: bei einem geboxten int konstruiert es ein Nullable<int> mit Wert; bei einer null-Referenz erzeugt es ein leeres Nullable<int>, statt die NullReferenceException zu werfen, die unbox.any auf jedem anderen Werttyp werfen würde. (int?)o ist also die eine Konvertierung in C#, die stillschweigend null in den Standardwert eines Werttyps verwandelt.
Für einen Decompiler ist das ein Fall von keinen Code erfinden. Die Anweisungen box und unbox.any nennen Nullable<int32> in ihrem Typoperanden, und die Versuchung ist, sie als Konvertierungsaufruf oder new int?(…) wiederzugeben. Der Autor schrieb eine schlichte implizite Konvertierung und einen schlichten Cast; das besondere Verhalten lebt in der Laufzeit-Implementierung zweier gewöhnlicher Anweisungen, und die richtige Ausgabe ist object o = a; und (int?)o, nichts weiter.
Was ein Decompiler falsch macht, und warum es zählt
Drei Fehlermodi tauchen in der Praxis auf, und alle sind Varianten davon, die Absenkung statt der Sprache wiederzugeben:
- Der
HasValue/GetValueOrDefault-Tanz vollständig ausgedruckt — korrekt, wortreich und ein zuverlässiges Zeichen, dass das Werkzeug keine Vorlagen für angehobene Operatoren hat. Eine Methode mit sechs Nullable-Additionen wird zu vierzig Zeilen Temporärer. new int?(x), wo die Quellexhatte — die implizite Konvertierung vonintzuint?ist in der IL einnewobj, und den Konstruktoraufruf auszugeben ist treu, aber laut. Glass gibt die Konvertierung implizit aus, wo immer der Zieltyp sie eindeutig macht.- Ein Konvertierungsaufruf um
box/unbox.any— eine Methode erfinden, die nicht in der IL ist, um ein Verhalten zu erklären, das in der Laufzeit ist.
Der Grund, warum das über Ästhetik hinaus zählt, ist, dass Nullable-Arithmetik sich genau in dem Code häuft, den ein Leser normalerweise verstehen will — optionale Konfiguration, Finanzberechnungen mit „nicht gesetzt”-Zuständen, musterreiche Domänenlogik — und die Absenkung seine Länge mit drei oder vier multipliziert. Ein Decompiler, der die Vorlagen zurückfaltet, gibt Ihnen die fünf Zeilen des Autors; einer, der es nicht tut, gibt Ihnen eine Seite Struct-Aufrufe, die alle gleich aussehen.
Die zugrunde liegende Lektion ist dieselbe, die ?. und ?? und foreach regiert: der C#-Compiler senkt Sprachfeatures in gewöhnliche IL über gewöhnliche Typen ab, und die Aufgabe des Decompilers ist es, jede dieser Formen gut genug zu kennen, um sie rückwärts laufen zu lassen. Nullable<T> ist nur darin ungewöhnlich, wie vollständig es verschwindet — in ein Struct auf dem Weg hinein und in ein schlichtes geboxtes int oder ein null auf dem Weg hinaus.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.