Skip to content
← Alle Beiträge
· Delta1 Labs DecompilationGlass

Die Range- und Index-Operatoren von C# dekompilieren: Slice und GetSubArray zurück zu a[^1] und a[1..^1] falten

Die Operatoren ^ und .. sind reiner Compiler-Zucker. a[^1] wird zu Arithmetik auf der Länge der Sammlung reduziert, a[1..^1] baut aus zwei System.Index-Werten einen System.Range und ruft RuntimeHelpers.GetSubArray auf, ein Span-Bereich wird zu Span<T>.Slice, und ein String-Bereich wird zu String.Substring. Ein naiver Dekompiler zeigt die rohen Konstruktoraufrufe und die Länge-minus-n-Arithmetik; Glass.NET erkennt diese Muster und faltet sie zurück zu ^ und .. — einschließlich des subtilen Falls, in dem span.Slice(1, 2) und span[1..3] byteidentisches IL erzeugen, sodass Glass die Lesart wählen muss, die einen verlustfreien Round-Trip ergibt.

Die Operatoren ^ (Index vom Ende) und .. (Bereich) gehören zur bequemsten Syntax, die C# hinzugefügt hat, und zur gründlichsten, die bis zum Erreichen des IL ausgelöscht wird. Es gibt keinen „Bereichs”-Opcode und keinen „vom Ende”-Opcode. a[^1] ist Arithmetik auf einer Länge; a[1..^1] ist ein Paar Konstruktoraufrufe und ein Helper; ein Span-Abschnitt ist ein Methodenaufruf; ein String-Bereich ist Substring. Wenn ein Dekompiler das Assembly sieht, sind die Operatoren verschwunden und nur ihr mechanischer Rückstand bleibt.

Dieser Beitrag geht diesen Rückstand für jede Form durch, zeigt, was ein naiver Dekompiler ausgibt, und zeigt, wie Glass.NET ihn zurück zu ^ und .. faltet — einschließlich des wirklich mehrdeutigen Falls, in dem zwei verschiedene Quellformen zu exakt denselben Bytes kompilieren.

Der Quellcode

Vier kleine Methoden, eine pro Reduktionsform:

int    Last(int[] a)          => a[^1];        // index from end
int[]  Middle(int[] a)        => a[1..^1];     // array range  -> GetSubArray
Span<int> Tail(Span<int> s)   => s[1..^1];     // span range   -> Slice
string Head(string s)         => s[..5];       // string range -> Substring

Jede kompiliert zu etwas, das weder ^ noch .. erwähnt.

a[^1]: ein Index, der nie zu einem Index wird

Die Intuition ist, dass ^1 einen System.Index mit fromEnd: true konstruiert. Er kann es — aber wenn der Index direkt auf einem Typ verwendet wird, der eine Länge und einen int-Indexer hat, überspringt der Compiler das Objekt ganz und reduziert zu reiner Arithmetik:

// int Last(int[] a) => a[^1];
IL_0000:  ldarg.1          // a
IL_0001:  ldarg.1          // a  (again, to read its length)
IL_0002:  ldlen
IL_0003:  conv.i4
IL_0004:  ldc.i4.1
IL_0005:  sub              // a.Length - 1
IL_0006:  ldelem.i4
IL_0007:  ret

Kein newobj, kein System.Index. Ein naiver Dekompiler liest das genau so, wie es dasteht, und gibt die Arithmetik aus:

int Last(int[] a) => a[a.Length - 1];

Das ist korrekt, verbirgt aber den Operator. Glass erkennt die Form — ein Längen-Load, eine Subtraktion um eine Konstante, die den Index eines Elementzugriffs auf derselben Instanz speist — und faltet sie zur Oberflächenform:

int Last(int[] a) => a[^1];

Der System.Index-Konstruktor überlebt nur, wenn der vom Ende gemessene Wert als Wert leben muss: var i = ^1; in einer lokalen Variable gespeichert, oder ein Indexer, dessen Parameter vom Typ System.Index ist. Dort sieht man tatsächlich ldc.i4.1; ldc.i4.1; newobj instance void System.Index::.ctor(int32, bool) — Wert 1, fromEnd: true — und Glass rendert diese lokale Variable als Index i = ^1;.

a[1..^1]: ein Range, zwei Index und GetSubArray

Array-Bereiche sind die reichhaltigste Form, denn einen Array zu zerschneiden alloziert einen neuen Array, und diese Kopie geht durch einen Runtime-Helper:

// int[] Middle(int[] a) => a[1..^1];
IL_0000:  ldarg.1
IL_0001:  ldc.i4.1
IL_0002:  ldc.i4.0
IL_0003:  newobj     instance void System.Index::.ctor(int32, bool)   // Index(1, fromEnd: false)
IL_0008:  ldc.i4.1
IL_0009:  ldc.i4.1
IL_000a:  newobj     instance void System.Index::.ctor(int32, bool)   // Index(1, fromEnd: true)
IL_000f:  newobj     instance void System.Range::.ctor(valuetype System.Index, valuetype System.Index)
IL_0014:  call       !!0[] [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::GetSubArray<int32>(!!0[], valuetype System.Range)
IL_0019:  ret

Der Gegenwert von vier Allokationen an Zeremonie kodiert 1..^1: ein Index(1, false) vom Anfang, ein Index(1, true) vom Ende, ein Range, der aus den beiden gebaut wird, und GetSubArray<int>, das intern range.GetOffsetAndLength(a.Length) aufruft, um den Bereich vor dem Kopieren in ein konkretes (start, length) umzuwandeln. Ein naiver Dekompiler gibt alles aus:

int[] Middle(int[] a) =>
    RuntimeHelpers.GetSubArray(a, new Range(new Index(1), new Index(1, true)));

Glass gleicht die gesamte Pipeline ab — ein Range, der aus zwei Index-Werten gebaut und an GetSubArray übergeben wird — und kollabiert sie zum Operator, indem es fromEnd: false als nackten Offset und fromEnd: true als ^ liest:

int[] Middle(int[] a) => a[1..^1];

Diese Form ist eindeutig: nichts außer Bereichszucker erzeugt einen GetSubArray-Aufruf, also kann Glass sie immer ohne Zögern falten.

Index-Zahlenstrahl (Länge 6)vom Ende ^6 ^5 ^4 ^3 ^2 ^1a0a1a2a3a4a5vom Anfang 0 1 2 3 4 5^1 = a[Length-1]Reduktions-Pipeline von a[1..^1]a[1..^1]System.RangeIndex(1, false)Index(1, true)GetOffsetAndLengthstart 1, length 4GetSubArray / SliceKopie (Array) / Ansicht (Span)Glass faltet die Kette zurück zuint[] Middle(int[] a) => a[1..^1];

s[1..^1] auf einem Span: Slice, ohne Kopie

Ein Span<T> alloziert bei einem Bereich nie. Der Compiler reduziert ihn zu Slice(start, length) und berechnet die Offsets aus der Länge des Spans. Für den Fall vom Ende sieht man den Längen-Load und die Subtraktion inline:

// Span<int> Tail(Span<int> s) => s[1..^1];
IL_0000:  ldarga.s   s
IL_0002:  call       instance int32 valuetype [System.Runtime]System.Span`1<int32>::get_Length()
IL_0007:  ldc.i4.1
IL_0008:  sub                                   // end  = Length - 1
IL_0009:  ldc.i4.1                              // start = 1
IL_000a:  sub                                   // length = (Length - 1) - 1
IL_000b:  ldarga.s   s
IL_000d:  ldc.i4.1                              // start again
IL_000e:  ???  call  Slice(int32, int32)

In der Praxis hebt Roslyn get_Length in eine temporäre Variable und ordnet die Operanden so, dass der finale Aufruf s.Slice(1, s.Length - 2) ist. Ein naiver Dekompiler gibt das wörtlich aus:

Span<int> Tail(Span<int> s) => s.Slice(1, s.Length - 1 - 1);

Glass erkennt get_Length, das Length - n in das Längen-Argument eines Slice speist, als die Vom-Ende-Markierung und stellt beide Grenzen wieder her:

Span<int> Tail(Span<int> s) => s[1..^1];

Wenn der Bereich ein Laufzeitwert statt einer Konstante ist — s[r], wobei r eine lokale Range-Variable ist —, stellt der Compiler die Arithmetik nicht inline; er ruft r.GetOffsetAndLength(s.Length) auf, das ein (int, int)-Tupel zurückgibt, und speist es in Slice. Glass faltet das zurück zu s[r].

s[..5] auf einem String: Substring

Strings erhalten eine dritte Reduktion. Ein String-Bereich wird zu Substring(start, length):

// string Head(string s) => s[..5];
IL_0000:  ldarg.1
IL_0001:  ldc.i4.0          // start = 0 (open-ended start)
IL_0002:  ldc.i4.5          // length = 5
IL_0003:  callvirt   instance string [System.Runtime]System.String::Substring(int32, int32)
IL_0008:  ret

Die naive Ausgabe liest das direkt: s.Substring(0, 5). Glass faltet die Form (0, Konstante) zum Bereich mit offenem Anfang s[..5].

Die Mehrdeutigkeit: span.Slice(1, 2) und span[1..3] sind dieselben Bytes

Hier ist die Feinheit, die das zu mehr als einer Nachschlagetabelle macht. Nimm einen Span-Bereich mit konstanten Grenzen, beide vom Anfang:

Span<int> a = span[1..3];      // range sugar
Span<int> b = span.Slice(1, 2); // explicit method call

Der Compiler faltet 1..3 zur Kompilierzeit zu start = 1, length = 3 - 1 = 2, sodass beide Zeilen byteidentisches IL emittieren:

ldarga.s   span
ldc.i4.1
ldc.i4.2
call       instance valuetype System.Span`1<int32> System.Span`1<int32>::Slice(int32, int32)

Nichts im Assembly sagt, welche Form der Autor geschrieben hat. Dasselbe gilt für s[1..6] gegenüber s.Substring(1, 5) auf einem String. Ein Dekompiler, der stets „die moderne Syntax bevorzugt”, schreibt jedes Slice(1, 2) zu [1..3] und jedes Substring zu einem Bereich um — einschließlich derer, die als explizite Methodenaufrufe geschrieben wurden.

Glass rät nicht. Seine Regel ist die Round-Trip-Treue: die Lesart emittieren, die zum selben IL rekompiliert und nichts behauptet, was die Bytes nicht verlangen. Für die nackte Form Slice(const, const) / Substring(const, const) ist diese Lesart der Methodenaufruf, also gibt Glass span.Slice(1, 2) und s.Substring(1, 5) wie geschrieben aus. Es emittiert .. und ^ nur, wenn das IL einen Beweis trägt, den nur Bereichszucker erzeugt:

  • einen System.Range::.ctor-Aufruf (der allgemeine Bereichspfad),
  • einen RuntimeHelpers.GetSubArray-Aufruf (Array-Bereiche),
  • einen Range.GetOffsetAndLength-Aufruf (laufzeitwertige Bereiche),
  • oder get_Length/ldlen-Arithmetik der Form Length - n, die einen Elementzugriff oder die Länge eines Slice speist (das Vom-Ende-^).

Jede dieser Formen kann zu byteidentischem IL rekompiliert werden, sodass die Faltung nachweislich sicher ist. Das nackte Slice kann nicht gefaltet werden, ohne eine Quellform zu behaupten, die das IL nicht verlangt — also lässt Glass es in Ruhe. Das Ergebnis liest sich ehrlich: Bereiche erscheinen dort, wo nachweislich Bereiche verwendet wurden, und Slice erscheint dort, wo der Autor vielleicht einfach Slice aufgerufen hat.

Es ausprobieren

Richte die CLI auf den Typ und lies die Methodenrümpfe zurück:

glass decompile MyApp.dll --type MyApp.Buffers
glass decompile MyApp.dll --type MyApp.Buffers --member Tail

Last, Middle und Tail kommen als a[^1], a[1..^1] und s[1..^1] zurück; ein echtes span.Slice(1, 2) kommt als Methodenaufruf zurück. Dieselbe Rekonstruktion treibt die Avalonia-Oberfläche an, also ist das gefaltete ^/.. das, was man liest, wenn man das Assembly in Glass öffnet.

Das Fazit

Range und Index sind Zucker, der sich vollständig im IL auflöst: ^1 in Length - 1, 1..^1 in einen System.Range aus zwei System.Index-Werten, der an GetSubArray oder Slice übergeben wird, und ein String-Bereich in Substring. Ein naiver Dekompiler zeigt den Rückstand — Konstruktoraufrufe, GetOffsetAndLength, Längen-Arithmetik —, was korrekt, aber unlesbar ist. Glass erkennt jede Form und faltet sie zurück zu ^ und .., während es sich weigert, die eine Form zu falten, die es nicht beweisen kann: ein nacktes konstantes Slice oder Substring, das Bereichszucker und ein expliziter Aufruf identisch kompilieren. Die Leitregel ist, dass jede Faltung einen Round-Trip ergeben muss, sodass die Oberflächensyntax, die Glass zeigt, nie eine als Tatsache verkleidete Vermutung ist.

Nebula.NET testen

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