params-Collections dekompilieren: Wo ist mein Array geblieben?
Zwanzig Jahre lang bedeutete ein params-Parameter in IL genau eines: Der Aufrufer baute ein newarr und füllte es. C# 13 hat params auf beliebige Collection-Typen verallgemeinert, und der Paradefall — params ReadOnlySpan<T> — allokiert überhaupt nichts auf dem Heap. Der Aufrufer legt die Argumente in einen compilergenerierten Inline-Array-Puffer auf dem Stack und wickelt einen Span darum; params List<T> läuft stattdessen über einen Collection Builder. Das Erkennungsmerkmal beim Dekompilieren liegt vollständig an der Aufrufstelle, nicht in der Signatur. Hier steht, was jede Form emittiert, wie ein Decompiler den Puffer wieder zu einem schlichten Aufruf zusammenfaltet und warum ein params ReadOnlySpan<T>, das ein newarr zeigt, ein kalter Fallback-Pfad ist, der Beachtung verdient.
params ist die älteste Bequemlichkeit in C#: Man schreibt string.Format("{0}+{1}", a, b), und der Compiler baut im Stillen das object[], das Format tatsächlich entgegennimmt. Zwanzig Jahre lang hatte dieses Desugaring genau eine Form — ein newarr, eine Reihe von stelem und der Aufruf — und jeder Decompiler lernte, sie wieder zu losen Argumenten zusammenzufalten.
C# 13 hat params auf beliebige Collection-Typen verallgemeinert, und das interessante Mitglied der neuen Familie, params ReadOnlySpan<T>, allokiert nichts auf dem Heap. Die Argumente landen in einem auf dem Stack allokierten Puffer, um den ein Span gewickelt wird. Die Methodensignatur ändert sich kaum; der gesamte Unterschied liegt an der Aufrufstelle. Ein Decompiler, der nach wie vor nur die newarr-Form kennt, zeigt einen compilergenerierten Puffertyp, den man nicht benennen kann, und einen Span-Konstruktor dort, wo der Quelltext einen schlichten Aufruf hatte.
Die Signatur bewegt sich kaum; die Aufrufstelle bewegt sich stark
Betrachten wir eine Logging-Methode, die von der klassischen Form zur Span-Form migriert wurde:
// Classic — unchanged since C# 1
public static void LogOld(params object[] args) { /* ... */ }
// C# 13 — one word of the type changes
public static void LogNew(params ReadOnlySpan<object> args) { /* ... */ }
Beide werden identisch aufgerufen: LogNew(id, name, DateTime.Now). Aber die IL, die der Aufrufer emittiert, ist völlig verschieden. Die klassische Form ist das vertraute Heap-Array:
// LogOld(id, name, now)
ldc.i4.3
newarr [System.Runtime]System.Object // allocate object[3] on the heap
dup
ldc.i4.0
ldarg.0
stelem.ref // args[0] = id
dup
ldc.i4.1
ldarg.1
stelem.ref // args[1] = name
// ... args[2] = now ...
call void Log::LogOld(object[])
Die Span-Form allokiert kein Array. Der Compiler emittiert einen compilergenerierten Inline-Array-Typ — eine Struct mit [InlineArray(3)], derselbe Mechanismus, den C# für Inline Arrays und stackalloc verwendet — als Lokale im Frame des Aufrufers, speichert jedes Argument über die Inline-Array-Intrinsics hinein, baut einen ReadOnlySpan<object> über den Puffer und übergibt diesen:
// LogNew(id, name, now)
.locals init ([0] valuetype '<>y__InlineArray3`1'<object> buffer)
ldloca.s buffer
initobj valuetype '<>y__InlineArray3`1'<object>
// element 0
ldloca.s buffer
ldc.i4.0
call !0& InlineArrayElementRef<'<>y__InlineArray3`1'<object>,object>(ref, int)
ldarg.0
stind.ref // buffer[0] = id
// ... buffer[1] = name, buffer[2] = now via the same intrinsic ...
ldloca.s buffer
ldc.i4.3
call ReadOnlySpan<object> InlineArrayAsReadOnlySpan<...>(ref, int)
call void Log::LogNew(valuetype ReadOnlySpan`1<object>)
Kein newarr, kein Heap-Objekt, kein GC-Druck. Der Puffer ist ein Werttyp auf dem Stack; er stirbt mit dem Frame. Jedes bisschen des Unterschieds liegt im Aufrufer — der Aufgerufene hat lediglich einen Span erhalten.
Die drei Formen, die ein Decompiler kennen muss
params hat jetzt eine kleine Familie von Lowerings, die nach dem Collection-Typ des Parameters gewählt werden. Ein Decompiler liest die Aufrufstelle, um sie auseinanderzuhalten, und muss alle wieder zu losen Argumenten zusammenfalten.
params T[] — der klassische Pfad, unverändert. Der Parametertyp ist ein Array, also emittiert der Aufrufer newarr + stelem + Aufruf. Ein Decompiler faltet das genau so zusammen wie immer. Nichts Neues hier, und das ist der Punkt: Alter Code behält seine alte IL.
params ReadOnlySpan<T> / params Span<T> — der allokationsfreie Pfad. Der <>y__InlineArrayN-Puffer, die Element-Stores über InlineArrayElementRef, der Aufruf von InlineArrayAsReadOnlySpan, dann der Zielaufruf. Die Erkennungsregel lautet: Eine compilergenerierte Inline-Array-Lokale, Element für Element befüllt und in einen Span gewickelt, der unmittelbar einen params-Aufruf speist, sind lose Argumente. Stelle sie als LogNew(id, name, now) dar, nicht als sichtbaren Puffer und Span-Konstruktor — der Typ <>y__InlineArray3 lässt sich in C#-Quelltext nicht benennen, also erzeugt seine wörtliche Ausgabe Code, der nicht wieder kompiliert.
params List<T> / params IEnumerable<T> — der Collection-Builder-Pfad. Für eine Nicht-Span-Collection verwendet der Compiler die Maschinerie der Collection Expressions wieder: Er baut die Collection (eine List<T> über ihren Add-Pfad oder die [CollectionBuilder]-Methode eines Typs) und übergibt die fertige Collection. Der Decompiler muss die Builder-Sequenz erkennen und ebenfalls zu losen Argumenten zusammenfalten.
Alle drei kollabieren zum selben Quelltext — foo(a, b, c) —, die Aufgabe des Decompilers besteht also darin, den Mechanismus auszulöschen und den Aufruf wiederherzustellen.
Das Erkennungsmerkmal: ein von newarr gespeister Span
Hier ist der subtile Fall, den es sich zu erwischen lohnt. Selbst ein params ReadOnlySpan<T>-Parameter bekommt nicht immer den Stack-Puffer. Der Compiler allokiert nur dann auf dem Stack, wenn er beweisen kann, dass der Span nicht entkommt; auf den Pfaden, wo er das nicht kann, fällt er auf ein Heap-Array zurück und wickelt den Span um dieses. Gelegentlich sieht man also:
ldc.i4.3
newarr [System.Runtime]System.Object // heap array, not an inline buffer
dup / stelem.ref ... // fill it
newobj instance void ReadOnlySpan<object>::.ctor(!0[]) // span over the array
call void Log::LogNew(valuetype ReadOnlySpan`1<object>)
Ein newarr, das einen ReadOnlySpan-Konstruktor speist, statt eines <>y__InlineArray-Puffers, ist der Fingerabdruck des Fallback-Pfads. Es ist nach wie vor ein korrekter params-Aufruf und faltet sich im dekompilierten Quelltext nach wie vor zu LogNew(id, name, now) zusammen — aber es sagt dir, dass dieser bestimmte Aufruf sehr wohl allokiert hat, und das ist genau das, was die Span-Form vermeiden sollte. Wenn man dekompilierte Ausgabe liest, um ein Allokationsprofil zu verstehen, ist diese Unterscheidung die ganze Geschichte: dieselbe Quelltextzeile, andere Kosten, und nur die IL zeigt, welche von beiden man bekommen hat.
Was ein guter Decompiler emittiert
Stellt man die Formen nebeneinander, ist die Verpflichtung klar. Für den allokationsfreien Aufruf zeigt ein Decompiler, der auf Metallebene stehen bleibt:
// Wrong: literal, and won't recompile — <>y__InlineArray3 isn't nameable
<>y__InlineArray3<object> buffer = default;
InlineArrayElementRef<...>(ref buffer, 0) = id;
// ...
LogNew(InlineArrayAsReadOnlySpan<...>(ref buffer, 3));
während die Wiederherstellung der Absicht des Autors ergibt:
// Right: the call the author actually wrote
LogNew(id, name, DateTime.Now);
Glass.NET faltet alle drei params-Formen — das klassische Array, den Inline-Array-Span und die Collection-Builder-Formen — wieder zu einem Aufruf mit losen Argumenten zusammen und behält ein explizites Span- oder Listen-Argument explizit bei, wenn der Quelltext tatsächlich eines übergeben hat statt loser Werte. Das Feature hat die interessante Arbeit von der Methodensignatur an die Aufrufstelle verlagert, also muss ein Decompiler dort hinschauen. Stelle den Aufruf wieder her, lass den Puffer weg und notiere das Allokations-Erkennungsmerkmal, wenn du es brauchst.
Siehe Inline Arrays und stackalloc für den Puffermechanismus, den dies wiederverwendet, und Collection Expressions für den Builder-Pfad, den die Nicht-Span-Formen teilen.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.