Decompilar los operadores de rango e índice de C#: plegar Slice y GetSubArray de vuelta a a[^1] y a[1..^1]
Los operadores ^ y .. son puro azúcar del compilador. a[^1] se reduce a aritmética contra la longitud de la colección, a[1..^1] construye un System.Range a partir de dos valores System.Index y llama a RuntimeHelpers.GetSubArray, un rango de span se convierte en Span<T>.Slice, y un rango de cadena se convierte en String.Substring. Un decompilador ingenuo muestra las llamadas a constructores en bruto y la aritmética Longitud menos n; Glass.NET reconoce estos patrones y los pliega de vuelta a ^ y .. — incluyendo el caso sutil en el que span.Slice(1, 2) y span[1..3] emiten IL idéntico byte a byte, así que Glass tiene que elegir la lectura que hace round-trip.
Los operadores ^ (índice desde el final) y .. (rango) son parte de la sintaxis más conveniente que ha añadido C#, y parte de la más exhaustivamente borrada para cuando el código llega al IL. No hay ningún opcode de «rango» ni ningún opcode de «desde el final». a[^1] es aritmética contra una longitud; a[1..^1] es un par de llamadas a constructores y un helper; un segmento de span es una llamada a método; un rango de cadena es Substring. Para cuando un decompilador ve el ensamblado, los operadores han desaparecido y solo queda su residuo mecánico.
Esta entrada recorre ese residuo para cada forma, muestra lo que imprime un decompilador ingenuo, y muestra cómo Glass.NET lo pliega de vuelta a ^ y .. —incluyendo el caso genuinamente ambiguo en el que dos formas de código fuente distintas se compilan a exactamente los mismos bytes.
El código fuente
Cuatro métodos pequeños, uno por forma de reducción:
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
Cada uno se compila a algo que no menciona ni ^ ni ...
a[^1]: un índice que nunca llega a ser un Index
La intuición es que ^1 construye un System.Index con fromEnd: true. Puede hacerlo —pero cuando el índice se usa directamente sobre un tipo que tiene una longitud y un indexador int, el compilador se salta el objeto por completo y reduce a aritmética pura:
// 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
Ningún newobj, ningún System.Index. Un decompilador ingenuo lee esto exactamente como está escrito e imprime la aritmética:
int Last(int[] a) => a[a.Length - 1];
Eso es correcto, pero oculta el operador. Glass reconoce la forma —una carga de longitud, una resta por una constante, que alimenta el índice de un acceso a elemento sobre la misma instancia— y la pliega a la forma de superficie:
int Last(int[] a) => a[^1];
El constructor de System.Index solo sobrevive cuando el valor relativo al final tiene que vivir como valor: var i = ^1; guardado en una variable local, o un indexador cuyo parámetro es de tipo System.Index. Ahí sí ves ldc.i4.1; ldc.i4.1; newobj instance void System.Index::.ctor(int32, bool) —valor 1, fromEnd: true— y Glass renderiza esa variable local como Index i = ^1;.
a[1..^1]: un Range, dos Index y GetSubArray
Los rangos de array son la forma más rica, porque seccionar un array asigna un array nuevo, y esa copia pasa por un helper del 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
El equivalente a cuatro asignaciones de ceremonia codifica 1..^1: un Index(1, false) desde el inicio, un Index(1, true) desde el final, un Range construido a partir de los dos, y GetSubArray<int> que internamente llama a range.GetOffsetAndLength(a.Length) para convertir el rango en un (start, length) concreto antes de copiar. Un decompilador ingenuo lo imprime todo:
int[] Middle(int[] a) =>
RuntimeHelpers.GetSubArray(a, new Range(new Index(1), new Index(1, true)));
Glass hace coincidir toda la tubería —un Range construido a partir de dos valores Index entregado a GetSubArray— y la colapsa al operador, leyendo fromEnd: false como un offset pelado y fromEnd: true como un ^:
int[] Middle(int[] a) => a[1..^1];
Esta forma es inequívoca: nada salvo el azúcar de rango produce una llamada a GetSubArray, así que Glass siempre puede plegarla sin dudar.
s[1..^1] sobre un span: Slice, sin copia
Un Span<T> nunca asigna en un rango. El compilador lo reduce a Slice(start, length), calculando los offsets a partir de la longitud del span. Para el caso desde el final puedes ver la carga de longitud y la resta en línea:
// 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 la práctica Roslyn eleva get_Length a un temporal y ordena los operandos para que la llamada final sea s.Slice(1, s.Length - 2). Un decompilador ingenuo lo imprime literalmente:
Span<int> Tail(Span<int> s) => s.Slice(1, s.Length - 1 - 1);
Glass reconoce get_Length alimentando Length - n al argumento de longitud de un Slice como el marcador de «desde el final» y restaura ambos extremos:
Span<int> Tail(Span<int> s) => s[1..^1];
Cuando el rango es un valor en tiempo de ejecución en lugar de una constante —s[r] donde r es una variable local Range— el compilador no pone la aritmética en línea; llama a r.GetOffsetAndLength(s.Length), que devuelve una tupla (int, int), y la alimenta a Slice. Glass lo pliega de vuelta a s[r].
s[..5] sobre una cadena: Substring
Las cadenas reciben una tercera reducción. Un rango de cadena se convierte en 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 salida ingenua lo lee directamente: s.Substring(0, 5). Glass pliega la forma (0, constante) al rango de inicio abierto s[..5].
La ambigüedad: span.Slice(1, 2) y span[1..3] son los mismos bytes
Aquí está la sutileza que hace que esto sea más que una tabla de consulta. Toma un rango de span con extremos constantes, ambos desde el inicio:
Span<int> a = span[1..3]; // range sugar
Span<int> b = span.Slice(1, 2); // explicit method call
El compilador pliega 1..3 a start = 1, length = 3 - 1 = 2 en tiempo de compilación, así que ambas líneas emiten IL idéntico byte a byte:
ldarga.s span
ldc.i4.1
ldc.i4.2
call instance valuetype System.Span`1<int32> System.Span`1<int32>::Slice(int32, int32)
No hay nada en el ensamblado que diga qué forma escribió el autor. Lo mismo ocurre con s[1..6] frente a s.Substring(1, 5) sobre una cadena. Un decompilador que siempre «prefiere la sintaxis moderna» reescribirá cada Slice(1, 2) como [1..3] y cada Substring como un rango —incluidos los que se escribieron como llamadas explícitas a métodos.
Glass no adivina. Su regla es la fidelidad de round-trip: emitir la lectura que recompila al mismo IL y no afirma nada que los bytes no exijan. Para la forma pelada Slice(const, const) / Substring(const, const), esa lectura es la llamada al método, así que Glass imprime span.Slice(1, 2) y s.Substring(1, 5) tal como están escritos. Solo emite .. y ^ cuando el IL lleva evidencia que solo el azúcar de rango produce:
- una llamada a
System.Range::.ctor(la vía general de rango), - una llamada a
RuntimeHelpers.GetSubArray(rangos de array), - una llamada a
Range.GetOffsetAndLength(rangos con valor en tiempo de ejecución), - o aritmética de
get_Length/ldlende la formaLength - nque alimenta un acceso a elemento o la longitud de unSlice(el^desde el final).
Cada una de esas formas puede recompilarse a IL idéntico byte a byte, así que el plegado es demostrablemente seguro. El Slice pelado no puede plegarse sin afirmar una forma de código fuente que el IL no exige —así que Glass lo deja en paz. El resultado se lee con honestidad: los rangos aparecen donde se demostró que se usaron rangos, y Slice aparece donde el autor pudo simplemente haber llamado a Slice.
Probarlo
Apunta la CLI al tipo y lee de vuelta los cuerpos de los métodos:
glass decompile MyApp.dll --type MyApp.Buffers
glass decompile MyApp.dll --type MyApp.Buffers --member Tail
Last, Middle y Tail vuelven como a[^1], a[1..^1] y s[1..^1]; un span.Slice(1, 2) genuino vuelve como la llamada al método. La misma reconstrucción impulsa la interfaz Avalonia, así que el ^/.. plegado es lo que lees cuando abres el ensamblado en Glass.
La conclusión
Rango e índice son azúcar que se disuelve por completo en el IL: ^1 en Length - 1, 1..^1 en un System.Range de dos valores System.Index entregado a GetSubArray o Slice, y un rango de cadena en Substring. Un decompilador ingenuo muestra el residuo —llamadas a constructores, GetOffsetAndLength, aritmética de longitud— que es correcto pero ilegible. Glass reconoce cada forma y la pliega de vuelta a ^ y .., al tiempo que se niega a plegar la única forma que no puede demostrar: un Slice o Substring constante pelado, que el azúcar de rango y una llamada explícita compilan de forma idéntica. La regla rectora es que cada plegado debe hacer round-trip, así que la sintaxis de superficie que Glass muestra nunca es una conjetura disfrazada de hecho.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.