C# 12 dekompilieren: Collection-Ausdrücke und primäre Konstruktoren
Collection-Ausdrücke und primäre Konstruktoren lesen sich wie einzelne syntaktische Ideen, doch der Compiler senkt sie je nach Zieltyp und je nachdem, ob ein Parameter erfasst wird, zu sehr unterschiedlichem IL ab. Hier steht genau, was `[1, 2, 3]`, Spreads und ein primärer Konstruktor erzeugen und wo ein Decompiler den Zucker zurückfalten kann gegenüber dort, wo er nur die abgesenkte Form zeigen kann.
C# 12 hat zwei Features hinzugefügt, die sich im Quelltext wie einzelne Ideen lesen, sich im IL aber in mehrere sehr unterschiedliche Formen auffächern. Ein Collection-Ausdruck — [1, 2, 3] oder [.. erste, .. zweite] — hat keinen eigenen Opcode; der Compiler wählt eine Absenkung anhand des Zieltyps, und die Wahl reicht von einem schlichten Array über einen auf dem Stack allozierten Puffer bis zu einem Aufruf einer Builder-Methode. Ein primärer Konstruktor an einer Klasse oder einem Struct ähnelt strukturell dem eines Records, senkt sich aber zu weit weniger ab. Beide Absenkungen genau durchzugehen erklärt, was ein Decompiler wie Glass.NET in den Zucker zurückfalten kann und wo er ehrlich nur die abgesenkte Form zeigen kann.
Collection-Ausdrücke: eine Syntax, mehrere Absenkungen
Die Schlüsseltatsache zu [1, 2, 3] ist, dass er zieltypisiert ist. Dieselben drei Zeichen kompilieren zu unterschiedlichem IL, je nachdem, woran sie zugewiesen werden.
Array-Ziel. int[] a = [1, 2, 3]; ist genau ein Array-Initialisierer. Bei durchweg konstanten primitiven Elementen legt der Compiler die Bytes in einem Feld von <PrivateImplementationDetails> ab und ruft RuntimeHelpers.InitializeArray auf:
ldc.i4.3
newarr [System.Runtime]System.Int32
dup
ldtoken field ... '<PrivateImplementationDetails>'::... // der 1,2,3-Blob
call void RuntimeHelpers::InitializeArray(Array, RuntimeFieldHandle)
Bei nicht konstanten Elementen gibt er newarr gefolgt von stelem pro Slot aus. So oder so ist das Byte für Byte, was new int[] { 1, 2, 3 } erzeugt, also zeigt ein Decompiler eine Array-Erzeugung — und darf sie als [1, 2, 3] oder als new int[] { ... } rendern; beide sind getreu, weil das IL identisch ist.
List<T>-Ziel. List<int> a = [1, 2, 3]; wird zu einer Konstruktion plus Add-Aufrufen (mit voreingestellter Kapazität, wenn die Länge bekannt ist):
newobj instance void List`1<int32>::.ctor()
dup
ldc.i4.1
callvirt instance void List`1<int32>::Add(!0)
dup
ldc.i4.2
callvirt instance void List`1<int32>::Add(!0)
...
Das ist die wichtigste Mehrdeutigkeit des Features: Ein Collection-Ausdruck zu List<T> senkt sich zu demselben IL ab wie der klassische Collection-Initialisierer new List<int> { 1, 2, 3 }. Kein Marker trennt sie. Ein Decompiler kann daher nicht wissen, welchen du geschrieben hast, und gibt einen von beiden aus — die meisten zeigen die Collection-Initialisierer-Form, weil sie die ältere, breiter kompatible Syntax ist. Nichts geht verloren; die Oberflächensyntax passt nur eventuell nicht zu deinem Quelltext.
Span<T>/ReadOnlySpan<T>-Ziel. Hier verdienen Collection-Ausdrücke ihren Platz, und hier hört das IL auf, nach irgendetwas auszusehen, das du von Hand schreiben könntest. Für einen ReadOnlySpan<int> aus Konstanten legt der Compiler die Daten in einem schreibgeschützten RVA-Feld ab und baut den Span mit RuntimeHelpers.CreateSpan<int> — ganz ohne Heap-Allokation. Für einen Span<T> aus nicht konstanten Elementen synthetisiert er einen Puffertyp, dekoriert mit [InlineArray(N)], alloziert ihn auf dem Stack, schreibt jedes Element und nimmt einen Span darüber:
.locals ( valuetype '<>y__InlineArray3`1'<int32> buffer )
// Null-Init des Puffers, dann pro Element:
ldloca.s buffer
call !0& InlineArrayElementRef<...>(ref buffer, index)
stind.i4 // buffer[i] = value
// dann:
ldloca.s buffer
call Span<int> InlineArrayAsSpan<...>(ref buffer, 3)
Der synthetisierte Typ <>y__InlineArrayN<T> trägt einen unaussprechlichen Namen und das [InlineArray]-Attribut, die zusammen ein verlässlicher Fingerabdruck sind. Ein C#-12-bewusster Decompiler erkennt das Muster und faltet es zu [...] zurück; einer, der es nicht ist, bringt den Inline-Array-Struct und den Span-Aufruf wortwörtlich an die Oberfläche — korrekt, aber nicht hübsch.
[CollectionBuilder]-Ziel. Typen wie ImmutableArray<T> kündigen eine Fabrik mit [CollectionBuilder(typeof(ImmutableArray), "Create")] an. Der Compiler materialisiert einen ReadOnlySpan<T> der Elemente (mit derselben Inline-Array- oder RVA-Technik wie oben) und übergibt ihn dem Builder:
// ... ReadOnlySpan<int> aus {1,2,3} wie oben bauen ...
call valuetype ImmutableArray`1<int32> ImmutableArray::Create<int32>(ReadOnlySpan`1<int32>)
Der Aufruf der benannten Builder-Methode, gespeist von einem Span, dessen Konstruktion die Methode im Quelltext nie gesehen hat, ist der Fingerabdruck. Ein Decompiler, der die Konvention kennt, rekonstruiert ImmutableArray<int> x = [1, 2, 3];; andernfalls zeigt er den expliziten Aufruf ImmutableArray.Create(span).
Spreads: erst die Länge, dann die Kopien
Ein Spread-Element (..) innerhalb eines Collection-Ausdrucks flacht eine andere Sequenz in diese ab. Die Absenkung spaltet sich danach, ob die Anzahl des Operanden billig zu erfahren ist.
Für int[] alle = [.. erste, .. zweite];, wo beide Operanden Arrays sind, summiert der Compiler die Längen, alloziert das Ziel einmal und kopiert jeden Operanden mit einem Index-Cursor — ohne Zwischenliste:
ldloc erste
ldlen // erste.Length
ldloc zweite
ldlen // zweite.Length
add // total
newarr int32 // Ziel einmal allozieren
// erste.CopyTo(dest, 0); zweite.CopyTo(dest, erste.Length);
Dieselbe Form gilt für jeden Operanden, der ein Length/Count bereitstellt, wobei CopyTo oder Array.Copy den Großteil bewegt. Wenn ein Operand ein nacktes IEnumerable<T> ist, dessen Anzahl unbekannt ist, kann der Compiler nicht vordimensionieren, also weicht er darauf aus, diesen Operanden mit einem foreach zu durchlaufen und an einen wachsenden Builder anzuhängen. Ein Decompiler rekonstruiert [.. a, .. b], wenn er das Skelett Summieren-Allozieren-Kopieren erkennt; wenn die Ausweich-Iteration beteiligt ist oder die Kopien durch Optimierung umsortiert wurden, zeigt er stattdessen eventuell die explizite Längenarithmetik und die CopyTo-Aufrufe, was die ehrliche Lesart dieses IL ist.
Primäre Konstruktoren: ein Konstruktor und Erfassungsfelder nur bei Bedarf
Ein primärer Konstruktor verschiebt die Parameter in die Typdeklaration:
public class Logger(ILog log, string prefix)
{
public void Info(string m) => log.Write(prefix + m);
}
Die Absenkung ist bewusst minimal. Der Compiler gibt einen gewöhnlichen Instanzkonstruktor aus, der (ILog log, string prefix) nimmt. Dann — und das ist der subtile Teil — synthetisiert er ein privates Feld nur für die Parameter, die ein Member tatsächlich außerhalb des Konstruktors und der Feldinitialisierer verwendet. Hier werden sowohl log als auch prefix innerhalb von Info gelesen, also werden beide erfasst und erhalten Hintergrundfelder mit unaussprechlichen, compilergenerierten Namen der Form <Parameter>P:
.field private initonly class ILog '<log>P'
.field private initonly string '<prefix>P'
.method ... .ctor(class ILog log, string prefix)
ldarg.0
call instance void object::.ctor()
ldarg.0 ldarg.1 stfld ILog Logger::'<log>P' // erfasst log
ldarg.0 ldarg.2 stfld string Logger::'<prefix>P' // erfasst prefix
ret
Info liest dann this.<log>P und this.<prefix>P. Würde ein Parameter nur verwendet, um ein Feld oder eine Eigenschaft zu initialisieren — public string Prefix = prefix; —, erhielte er kein Erfassungsfeld; der Konstruktor läse ihn einmal, um Prefix zu setzen, und verwürfe ihn. Ungenutzte Parameter erzeugen gar nichts. Und ein Basisaufruf, class Audited(int id) : Base(id), reicht das Argument einfach im synthetisierten Konstruktor weiter.
Der Kontrast zu Records ist der ganze Punkt. Der primäre Konstruktor eines Records erzeugt öffentliche init-only-Eigenschaften, Wertgleichheit, GetHashCode, ToString/PrintMembers, eine Klonmethode und Deconstruct — einen reichen, erkennbaren Fingerabdruck. Ein primärer Konstruktor einer Klasse erzeugt nichts davon. Er produziert einen Konstruktor und höchstens einige private Erfassungsfelder. Genau dieser Minimalismus macht ihn schwer rückgewinnbar: Ein Konstruktor, der Parameter an private readonly-Felder zuweist, ist genau das, was man seit Jahrzehnten von Hand schreibt.
Ein Decompiler rekonstruiert class Logger(ILog log, string prefix) daher nur, wenn er die Erfassungsfeld-Namenskonvention <Parameter>P und die Eins-zu-eins-Zuweisung von Parameter zu Feld im Konstruktor erkennt. Wenn er das tut, kommt die primäre Konstruktor-Syntax sauber zurück. Wenn nicht — oder wenn die erfassten Felder durch Obfuskation umbenannt wurden oder du tatsächlich den expliziten Konstruktor geschrieben hast —, zeigt er das Äquivalent class Logger { private readonly ILog log; … public Logger(ILog log, string prefix) { … } }. Beide Formen kompilieren zu demselben IL, also ist das explizite Rendering kein Fehlschlag; es ist die getreue Lesart, wenn der einzige unterscheidende Marker des Zuckers fehlt.
Was aus der dekompilierten Ansicht mitzunehmen ist
Beide Features bekräftigen dieselbe Lektion über das Lesen von kompiliertem .NET: Die Oberflächensyntax ist eine Wahl über dem IL, keine Eigenschaft davon. Ein Collection-Ausdruck ist diejenige von vier Absenkungen, die sein Ziel verlangte, und nur einige davon tragen einen Fingerabdruck, der stark genug ist, um die Klammern zurückzubauen; zu List<T> ist er buchstäblich nicht von einem Collection-Initialisierer zu unterscheiden. Ein primärer Konstruktor einer Klasse ist ein Konstruktor plus Erfassungsfelder namens <x>P, ohne jede Record-Maschinerie, die Records leicht erkennbar macht. Ein guter Decompiler faltet zurück, was er beweisen kann, und zeigt den abgesenkten Code dort, wo er es nicht kann — und die IL-Ansicht ist der Ort, an dem du bestätigst, was geschah. Öffne das Assembly in Glass.NET, schalte eine Methode auf IL um und sieh zu, wie [1, 2, 3] zu einem newarr oder einem Inline-Array-Puffer wird. Für den vollständigen Ablauf siehe wie man eine .NET-DLL zu C# dekompiliert.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.