Skip to content
← Tous les articles
· Delta1 Labs DecompilationGlass

Décompiler les opérateurs de plage et d'index de C# : replier Slice et GetSubArray vers a[^1] et a[1..^1]

Les opérateurs ^ et .. sont du pur sucre syntaxique. a[^1] se réduit à de l'arithmétique sur la longueur de la collection, a[1..^1] construit un System.Range à partir de deux valeurs System.Index et appelle RuntimeHelpers.GetSubArray, une plage de span devient Span<T>.Slice, et une plage de chaîne devient String.Substring. Un décompilateur naïf affiche les appels de constructeur bruts et l'arithmétique Longueur moins n ; Glass.NET reconnaît ces motifs et les replie vers ^ et .. — y compris le cas subtil où span.Slice(1, 2) et span[1..3] émettent un IL identique octet pour octet, de sorte que Glass doit choisir la lecture qui fait un aller-retour fidèle.

Les opérateurs ^ (index depuis la fin) et .. (plage) font partie de la syntaxe la plus commode que C# ait ajoutée, et de la plus complètement effacée lorsque le code atteint l’IL. Il n’y a pas d’opcode « plage » ni d’opcode « depuis la fin ». a[^1] est de l’arithmétique sur une longueur ; a[1..^1] est une paire d’appels de constructeur et un helper ; un segment de span est un appel de méthode ; une plage de chaîne est Substring. Lorsqu’un décompilateur voit l’assembly, les opérateurs ont disparu et il ne reste que leur résidu mécanique.

Cet article parcourt ce résidu pour chaque forme, montre ce qu’un décompilateur naïf affiche, et montre comment Glass.NET le replie vers ^ et .. — y compris le cas véritablement ambigu où deux formes de code source différentes se compilent vers exactement les mêmes octets.

Le code source

Quatre petites méthodes, une par forme de réduction :

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

Chacune se compile vers quelque chose qui ne mentionne ni ^ ni ...

a[^1] : un index qui ne devient jamais un Index

L’intuition veut que ^1 construise un System.Index avec fromEnd: true. Il le peut — mais quand l’index est utilisé directement sur un type qui a une longueur et un indexeur int, le compilateur saute entièrement l’objet et se réduit à de l’arithmétique simple :

// 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

Aucun newobj, aucun System.Index. Un décompilateur naïf lit cela exactement tel quel et affiche l’arithmétique :

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

C’est correct, mais cela cache l’opérateur. Glass reconnaît la forme — un chargement de longueur, une soustraction par une constante, alimentant l’index d’un accès à un élément sur la même instance — et la replie vers la forme de surface :

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

Le constructeur de System.Index ne survit que lorsque la valeur relative à la fin doit vivre comme une valeur : var i = ^1; stocké dans une variable locale, ou un indexeur dont le paramètre est de type System.Index. Là, vous voyez bien ldc.i4.1; ldc.i4.1; newobj instance void System.Index::.ctor(int32, bool) — valeur 1, fromEnd: true — et Glass rend cette variable locale comme Index i = ^1;.

a[1..^1] : un Range, deux Index et GetSubArray

Les plages de tableau sont la forme la plus riche, car découper un tableau alloue un nouveau tableau, et cette copie passe par un helper du runtime :

// 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

L’équivalent de quatre allocations de cérémonie encode 1..^1 : un Index(1, false) depuis le début, un Index(1, true) depuis la fin, un Range construit à partir des deux, et GetSubArray<int> qui appelle en interne range.GetOffsetAndLength(a.Length) pour convertir la plage en un (start, length) concret avant de copier. Un décompilateur naïf affiche le tout :

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

Glass fait correspondre tout le pipeline — un Range construit à partir de deux valeurs Index passé à GetSubArray — et l’effondre vers l’opérateur, lisant fromEnd: false comme un offset nu et fromEnd: true comme un ^ :

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

Cette forme est non ambiguë : rien sauf le sucre de plage ne produit un appel à GetSubArray, donc Glass peut toujours la replier sans hésiter.

Droite numérique d''index (longueur 6)depuis la fin ^6 ^5 ^4 ^3 ^2 ^1a0a1a2a3a4a5depuis le début 0 1 2 3 4 5^1 = a[Length-1]Pipeline de réduction de a[1..^1]a[1..^1]System.RangeIndex(1, false)Index(1, true)GetOffsetAndLengthstart 1, length 4GetSubArray / Slicecopie (tableau) / vue (span)Glass replie la chaîne versint[] Middle(int[] a) => a[1..^1];

s[1..^1] sur un span : Slice, sans copie

Un Span<T> n’alloue jamais sur une plage. Le compilateur la réduit à Slice(start, length), calculant les offsets à partir de la longueur du span. Pour le cas depuis la fin, vous pouvez voir le chargement de longueur et la soustraction en ligne :

// 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)

En pratique, Roslyn hisse get_Length dans un temporaire et ordonne les opérandes pour que l’appel final soit s.Slice(1, s.Length - 2). Un décompilateur naïf l’affiche littéralement :

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

Glass reconnaît get_Length alimentant Length - n dans l’argument de longueur d’un Slice comme le marqueur « depuis la fin » et restaure les deux bornes :

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

Lorsque la plage est une valeur d’exécution plutôt qu’une constante — s[r] où r est une variable locale Range — le compilateur ne met pas l’arithmétique en ligne ; il appelle r.GetOffsetAndLength(s.Length), qui renvoie un tuple (int, int), et l’alimente à Slice. Glass le replie vers s[r].

s[..5] sur une chaîne : Substring

Les chaînes reçoivent une troisième réduction. Une plage de chaîne devient 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

La sortie naïve la lit directement : s.Substring(0, 5). Glass replie la forme (0, constante) vers la plage à début ouvert s[..5].

L’ambiguïté : span.Slice(1, 2) et span[1..3] sont les mêmes octets

Voici la subtilité qui fait de cela plus qu’une table de correspondance. Prenez une plage de span avec des bornes constantes, toutes deux depuis le début :

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

Le compilateur replie 1..3 vers start = 1, length = 3 - 1 = 2 à la compilation, de sorte que les deux lignes émettent un IL identique octet pour octet :

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

Rien dans l’assembly ne dit quelle forme l’auteur a écrite. Il en va de même pour s[1..6] contre s.Substring(1, 5) sur une chaîne. Un décompilateur qui « préfère toujours la syntaxe moderne » réécrira chaque Slice(1, 2) en [1..3] et chaque Substring en plage — y compris ceux qui ont été écrits comme des appels de méthode explicites.

Glass ne devine pas. Sa règle est la fidélité de l’aller-retour : émettre la lecture qui recompile vers le même IL et n’affirme rien que les octets n’exigent. Pour la forme nue Slice(const, const) / Substring(const, const), cette lecture est l’appel de méthode, donc Glass affiche span.Slice(1, 2) et s.Substring(1, 5) tels qu’écrits. Il n’émet .. et ^ que lorsque l’IL porte une preuve que seul le sucre de plage produit :

  • un appel à System.Range::.ctor (la voie générale de plage),
  • un appel à RuntimeHelpers.GetSubArray (plages de tableau),
  • un appel à Range.GetOffsetAndLength (plages à valeur d’exécution),
  • ou de l’arithmétique get_Length/ldlen de la forme Length - n alimentant un accès à un élément ou la longueur d’un Slice (le ^ depuis la fin).

Chacune de ces formes peut être recompilée vers un IL identique octet pour octet, donc le repli est démontrablement sûr. Le Slice nu ne peut pas être replié sans affirmer une forme de code source que l’IL n’exige pas — alors Glass le laisse tel quel. Le résultat se lit honnêtement : les plages apparaissent là où il a été prouvé que des plages étaient utilisées, et Slice apparaît là où l’auteur a peut-être simplement appelé Slice.

L’essayer

Pointez la CLI vers le type et relisez les corps de méthode :

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

Last, Middle et Tail reviennent comme a[^1], a[1..^1] et s[1..^1] ; un véritable span.Slice(1, 2) revient comme l’appel de méthode. La même reconstruction pilote l’interface Avalonia, donc le ^/.. replié est ce que vous lisez quand vous ouvrez l’assembly dans Glass.

À retenir

Plage et index sont du sucre qui se dissout complètement dans l’IL : ^1 en Length - 1, 1..^1 en un System.Range de deux valeurs System.Index passé à GetSubArray ou Slice, et une plage de chaîne en Substring. Un décompilateur naïf montre le résidu — appels de constructeur, GetOffsetAndLength, arithmétique de longueur — ce qui est correct mais illisible. Glass reconnaît chaque forme et la replie vers ^ et .., tout en refusant de replier la seule forme qu’il ne peut pas prouver : un Slice ou Substring constant nu, que le sucre de plage et un appel explicite compilent à l’identique. La règle directrice est que chaque repli doit faire un aller-retour, de sorte que la syntaxe de surface que Glass affiche n’est jamais une conjecture déguisée en fait.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.