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

a?.b?.c dekompilieren: wie der Null-Conditional-Operator zu Verzweigungen und einem gemeinsamen Sprungziel wird

Der Null-Conditional-Operator liest sich wie ein einziges aufgeräumtes Symbol — order?.Customer?.Name —, aber es gibt keine ?.-Instruktion in IL. Der C#-Compiler senkt ihn zu gewöhnlichen Nullprüfungen und Verzweigungen ab, und das Detail, das eine Kette sauber dekompilierbar macht, ist, dass jedes Glied zum selben gemeinsamen Ziel kurzschließt, nicht in eine eigene Verschachtelung. Dieser Beitrag liest die echte IL einer zweigliedrigen Kette, zeigt, warum der erste Empfänger mit einem nackten brtrue geprüft wird, während ein Zwischenergebnis per dup und pop behandelt wird, geht das Nullable-Lifting durch, das s?.Length in ein int? mit initobj auf der Null-Seite und einem Nullable..ctor auf der anderen verwandelt, behandelt die ??- und ??=-Absenkungen, die dieselbe Kurzschlussform teilen, und erklärt, wie Glass.NET das Muster erkennt und die Verzweigungen zurück in ?. und ?? faltet, statt Ihnen ein Labyrinth aus Labels zu hinterlassen.

Der Null-Conditional-Operator ist eines der kompaktesten Dinge in C#. order?.Customer?.Name sagt „laufe diese Kette ab, und sobald irgendein Glied null ist, halte an und gib mir null“ — in einer einzigen Zeile ohne Klammern und ohne Temporäre. Diese Kompaktheit ist vollständig eine Illusion der Quellsprache. Es gibt keine ?.-Instruktion in IL, keinen „Safe-Navigation“-Grundbaustein in der Laufzeit. Wenn der Compiler fertig ist, ist der Operator zu genau den Loads, Calls und bedingten Sprüngen geworden, die Sie geschrieben hätten, wenn Sie jede Nullprüfung von Hand ausbuchstabiert hätten. Ihn gut zu dekompilieren heißt, diese Form zu erkennen und das ?. wieder einzusetzen.

Ein Glied

Beginnen wir mit dem einfachsten Fall, einem einzelnen ?. über Referenztypen:

string? name = customer?.Name;   // customer is Customer?, Name is string

Die abgesenkte IL ist eine Nullprüfung und ein Sprung:

ldarg.1          // customer
brtrue.s   HasValue
ldnull           // customer was null -> the whole expression is null
br.s       Done
HasValue:
ldarg.1
callvirt   instance string Customer::get_Name()
Done:
stloc.0          // name

Nichts Exotisches: den Empfänger auswerten, mit brtrue testen, bei null ldnull pushen und ans Ende springen; bei nicht-null den Getter aufrufen. Das Ergebnis beider Pfade landet im selben Slot. Ein Decompiler, der je nur diesen einen Fall gesehen hätte, könnte ihn direkt per Muster abgleichen. Der Grund, warum ?. beim Dekompilieren interessant ist, ist das, was passiert, wenn man es verkettet.

Eine Kette teilt ein Sprungziel

string? name = order?.Customer?.Name;

Die C#-Spezifikation verlangt, dass die ganze Kette als Einheit kurzschließt — ist order null, wird Customer nie angefasst und Name nie angefasst, und das Ergebnis ist ein einziges null. Eine naive Implementierung könnte eine Nullprüfung in die andere verschachteln. Der Compiler tut etwas Saubereres und Aufschlussreicheres: Er gibt der gesamten Kette ein gemeinsames Sprungziel, und jedes Glied verzweigt dorthin.

ldloc.0            // order
brtrue.s   Link1
Null:              // <-- the single shared skip target for the whole chain
ldnull
br.s       Done
Link1:
ldloc.0
callvirt   instance Customer Order::get_Customer()
dup                // intermediate result is a computed value, not a local
brtrue.s   Link2
pop                // discard the null Customer we just dup-ed
br.s       Null    // reuse the shared target
Link2:
callvirt   instance string Customer::get_Name()
Done:
stloc.1            // name

Zwei Details in dieser IL tragen die ganze Geschichte. Erstens wird das Null:-Label von beiden Gliedfehlern erreicht — der brtrue-Fehlschlag auf order fällt hinein, und die Customer-Nullprüfung springt mit br.s Null dorthin zurück. Es gibt einen Null-Ausgang für die gesamte Kette, nicht einen pro Glied. Dieses eine gemeinsame Ziel ist der Fingerabdruck einer einzelnen ?.-Kette, und genau das erlaubt es einem Decompiler, die IL zurück zu order?.Customer?.Name zu glätten statt zu einem verschachtelten order?.(Customer?.Name) mit redundanten Prüfungen.

Zweitens fällt die Asymmetrie zwischen den beiden Nullprüfungen auf. order ist eine Lokale, also lädt der Compiler sie einfach neu (ldloc.0) und testet mit brtrue — eine Lokale neu zu laden ist gratis, es gibt also keinen Grund, eine Kopie zu behalten. Customer dagegen ist ein berechneter Wert, den ein Call zurückgibt; den kann man nicht billig neu berechnen, also dup-t der Compiler das Ergebnis, testet die Kopie und pop-t sie auf dem Nullpfad. Dieses dup … brtrue … pop-Trio um einen Zwischenmember ist das Kennzeichen dafür, dass hier ein inneres Glied einer ?.-Kette lebt.

order prüfenbrtrue Link1get_Customerdup · brtrue Link2get_Namedurchfallen → DoneNull:ldnull → DoneDone: stloc nameorder ist nullCustomer ist nullWertpfad

Werttypen: das Nullable-Lifting

Die Kette oben erzeugte einen string, einen Referenztyp, also ist null einfach ldnull. Wenn der Member nach ?. einen nicht-nullbaren Werttyp zurückgibt, ist der Ergebnistyp des ganzen Ausdrucks seine nullbare Form — s?.Length ist int?, nicht int, weil er null sein können muss. Der Compiler setzt das mit einer Nullable<int>-Lokalen und zwei verschiedenen Arten um, sie zu füllen:

int? length = s?.Length;         // s is string?, Length is int -> result is int?
ldarg.1              // s
brtrue.s   HasValue
ldloca.s   V_0       // address of the int? local
initobj    valuetype [System.Runtime]System.Nullable`1<int32>
ldloc.0              // the default(int?) we just built: HasValue == false
br.s       Done
HasValue:
ldarg.1
callvirt   instance int32 System.String::get_Length()
newobj     instance void valuetype Nullable`1<int32>::.ctor(!0)  // wrap the int
Done:
stloc.0              // length (an int?)

Der Nullpfad führt initobj auf der Lokalen aus, was sie nullt — und ein genulltes Nullable<int> ist HasValue == false, also default(int?). Der Wertpfad ruft get_Length auf und verpackt den rohen int mit newobj Nullable<int>..ctor. Beide laufen auf der Lokalen zusammen. Dieses Paar initobj-bei-null / Nullable..ctor-bei-Wert ist die Signatur eines Null-Conditionals, der von einem Werttyp in sein Nullable-Lifting übergegangen ist, und ein Decompiler, der es erkennt, gibt s?.Length aus, statt die Nullable-Mechanik als handgeschriebenen Code an die Oberfläche zu bringen.

Derselbe Grundbaustein: ?? und ??=

Sobald man ?. als „einmal auswerten, auf null testen, darüber hinwegspringen“ sieht, sind die Coalescing-Operatoren offensichtlich derselbe Grundbaustein mit anderen Dingen auf jeder Seite der Verzweigung. a ?? b:

ldloc.0        // a
dup
brtrue.s   Use   // a is non-null: keep it, skip b
pop              // drop the null
ldloc.1        // b
Use:

a wird dup-t, damit die Nicht-Null-Kopie als Ergebnis auf dem Stack bleiben kann; ist es null, wird es pop-t und stattdessen b ausgewertet. Die Verbundzuweisung a ??= b ist derselbe Test um einen Store gewickelt — „wenn a null ist, weise ihm b zu“:

ldsfld     Config::_cache
dup
brtrue.s   Keep       // already set: keep it as the value
pop
call       Config::Build()
dup
stsfld     Config::_cache   // assign, and leave the value on the stack
Keep:

Alle drei Operatoren bestehen aus einmal auswerten, auf null testen, darüber hinwegspringen. Diese gemeinsame Form ist der Grund, warum ein Decompiler sie als Familie behandelt: Er sucht das dup/brtrue/pop-Idiom und fragt, was auf der Ersatzseite steht — ein Memberzugriff bedeutet ?., ein schlichter Wert ??, ein Store zurück in die Quelle ??=.

Was Glass daraus macht

Glass.NET dekompiliert diese Formen durch Mustererkennung, nicht durch Abschreiben der Verzweigungen. Wenn es einen einmal ausgewerteten, auf null getesteten Empfänger sieht, der zu einem Ziel kurzschließt, das null oder default liefert, rekonstruiert es ?.; wenn mehrere solcher Glieder ein einziges Sprungziel teilen, faltet es sie zu einer flachen Kette — order?.Customer?.Name — statt zu einem Nest von Bedingungen. Es erkennt das initobj/Nullable..ctor-Zusammenlaufen als Nullable-Lifting und schreibt s?.Length mit dem richtigen int?-Typ, und es kollabiert das dup/brtrue/pop-Idiom je nach Ersatzseite zu ?? oder ??=. Die Ränder sind dort, wo es zu äquivalentem, aber anders aussehendem Quelltext degradieren kann: Ein ?., dessen Ergebnis in einer Anweisung verworfen wird (logger?.Flush();), wird zu einem if (logger is not null), weil es keinen Wert zu tragen gibt, und eine Kette, die mit von Hand geschriebenen expliziten if-Prüfungen gemischt ist, kann als das eine oder das andere zurückgelesen werden, da die IL sie nicht mehr unterscheidet. Das Verhalten ist identisch; nur die Oberflächensyntax kann von dem abweichen, was Sie getippt haben.

Das ist der rote Faden beim Dekompilieren abgesenkter Operatoren: Der Compiler löscht die bequeme Syntax bis auf Verzweigungen herunter, und ein guter Decompiler holt sie zurück, indem er die Form kennt, die der Compiler immer emittiert. Siehe die Iterator-Zustandsmaschine dekompilieren und switch über Strings für zwei weitere Operatoren, die in IL verschwinden und nur zurückkommen, wenn man das Muster kennt. Glass.NET ist ein kostenloser .NET-Decompiler; richten Sie ihn auf eine Assembly, die stark auf ?. setzt, und sehen Sie zu, wie sich die Verzweigungen zurück in den Operator falten, den Sie geschrieben haben.

Nebula.NET testen

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