Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompilaciónC#

Decompilar C# 12: expresiones de colección y constructores primarios

Las expresiones de colección y los constructores primarios parecen ideas sintácticas únicas, pero el compilador los reduce a IL muy distinto según el tipo de destino y según si un parámetro se captura. Aquí tienes exactamente qué emiten `[1, 2, 3]`, los spreads y un constructor primario, y dónde un decompilador puede replegar el azúcar frente a dónde solo puede mostrar la forma reducida.

C# 12 añadió dos características que se leen como ideas únicas en el código fuente pero que se despliegan en varias formas muy distintas en IL. Una expresión de colección — [1, 2, 3], o [.. primero, .. segundo] — no tiene opcode propio; el compilador elige una reducción a partir del tipo de destino, y la elección va desde un array simple hasta un búfer asignado en pila o una llamada a un método constructor. Un constructor primario en una clase o struct se parece estructuralmente al de un record, pero se reduce a mucho menos. Recorrer ambas reducciones con precisión explica qué puede replegar un decompilador como Glass.NET en el azúcar, y dónde solo puede mostrar honestamente la forma reducida.

Expresiones de colección: una sintaxis, varias reducciones

El hecho clave sobre [1, 2, 3] es que está tipado por destino. Los mismos tres caracteres compilan a IL distinto según a qué se asignen.

Destino de array. int[] a = [1, 2, 3]; es exactamente un inicializador de array. Para elementos primitivos todos constantes, el compilador guarda los bytes en un campo de <PrivateImplementationDetails> y llama a RuntimeHelpers.InitializeArray:

ldc.i4.3
newarr     [System.Runtime]System.Int32
dup
ldtoken    field ... '<PrivateImplementationDetails>'::...    // el blob 1,2,3
call       void RuntimeHelpers::InitializeArray(Array, RuntimeFieldHandle)

Para elementos no constantes emite newarr seguido de stelem por hueco. En cualquier caso esto es byte a byte lo que produce new int[] { 1, 2, 3 }, así que un decompilador muestra una creación de array — y puede renderizarla como [1, 2, 3] o como new int[] { ... }; ambas son fieles porque el IL es idéntico.

Destino List<T>. List<int> a = [1, 2, 3]; se vuelve una construcción más llamadas Add (con una capacidad preestablecida cuando la longitud se conoce):

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

Esta es la ambigüedad más importante de la característica: una expresión de colección hacia List<T> se reduce al mismo IL que el inicializador de colección clásico new List<int> { 1, 2, 3 }. No hay marcador que los separe. Por tanto un decompilador no puede saber cuál escribiste y mostrará una de las dos — la mayoría muestra la forma de inicializador de colección porque es la sintaxis más antigua y ampliamente compatible. Nada se pierde; simplemente la sintaxis de superficie puede no coincidir con tu código fuente.

Destino Span<T>/ReadOnlySpan<T>. Aquí es donde las expresiones de colección se ganan el sueldo, y donde el IL deja de parecerse a nada que pudieras escribir a mano. Para un ReadOnlySpan<int> de constantes, el compilador guarda los datos en un campo RVA de solo lectura y construye el span con RuntimeHelpers.CreateSpan<int> — sin asignación en el montón. Para un Span<T> de elementos no constantes, sintetiza un tipo de búfer decorado con [InlineArray(N)], lo asigna en pila, escribe cada elemento y toma un span sobre él:

.locals ( valuetype '<>y__InlineArray3`1'<int32> buffer )
// cero-init del búfer, luego por cada elemento:
ldloca.s   buffer
call       !0& InlineArrayElementRef<...>(ref buffer, index)
stind.i4                                   // buffer[i] = value
// luego:
ldloca.s   buffer
call       Span<int> InlineArrayAsSpan<...>(ref buffer, 3)

El tipo sintetizado <>y__InlineArrayN<T> tiene un nombre impronunciable y el atributo [InlineArray], que juntos son una huella fiable. Un decompilador consciente de C# 12 reconoce el patrón y lo repliega a [...]; uno que no lo hace mostrará el struct de inline array y la llamada al span tal cual — correcto, pero no bonito.

Destino [CollectionBuilder]. Tipos como ImmutableArray<T> anuncian una fábrica con [CollectionBuilder(typeof(ImmutableArray), "Create")]. El compilador materializa un ReadOnlySpan<T> de los elementos (usando la misma técnica de inline array o RVA de arriba) y lo pasa al constructor:

// ... construir ReadOnlySpan<int> de {1,2,3} como arriba ...
call       valuetype ImmutableArray`1<int32> ImmutableArray::Create<int32>(ReadOnlySpan`1<int32>)

La llamada al método constructor designado, alimentada por un span que el método nunca vio construir en el código fuente, es la huella. Un decompilador que conoce la convención reconstruye ImmutableArray<int> x = [1, 2, 3];; en caso contrario muestra la llamada explícita ImmutableArray.Create(span).

[1, 2, 3]según destinoel destino decideint[]newarr / InitializeArrayList<T>new List + Add, Add, …Span<T>inline array en pilaImmutableArray<T>llamada [CollectionBuilder]formas de ILpor destinoreplegar[1, 2, 3]reconocida

Spreads: primero la longitud, luego las copias

Un elemento spread (..) dentro de una expresión de colección aplana otra secuencia en esta. La reducción se divide según si el recuento del operando es barato de aprender.

Para int[] todos = [.. primero, .. segundo]; donde ambos operandos son arrays, el compilador suma las longitudes, asigna el destino una sola vez y copia cada operando con un cursor de índice — sin lista intermedia:

ldloc primero
ldlen                        // primero.Length
ldloc segundo
ldlen                        // segundo.Length
add                          // total
newarr    int32              // asignar el destino una sola vez
// primero.CopyTo(dest, 0); segundo.CopyTo(dest, primero.Length);

La misma forma aplica a cualquier operando que exponga un Length/Count, con CopyTo o Array.Copy moviendo el grueso. Cuando un operando es un IEnumerable<T> pelado cuyo recuento se desconoce, el compilador no puede predimensionar, así que recurre a iterar ese operando con un foreach y añadir a un constructor que crece. Un decompilador reconstruye [.. a, .. b] cuando reconoce el esqueleto sumar-asignar-copiar; cuando interviene la iteración de reserva, o las copias fueron reordenadas por optimización, puede en cambio mostrar la aritmética explícita de longitudes y las llamadas CopyTo, que es la lectura honesta de ese IL.

Constructores primarios: un constructor, y campos de captura solo si hacen falta

Un constructor primario mueve los parámetros a la declaración del tipo:

public class Logger(ILog log, string prefix)
{
    public void Info(string m) => log.Write(prefix + m);
}

La reducción es deliberadamente mínima. El compilador emite un constructor de instancia corriente que toma (ILog log, string prefix). Luego — y esta es la parte sutil — sintetiza un campo privado solo para los parámetros que un miembro usa realmente fuera del constructor y de los inicializadores de campo. Aquí tanto log como prefix se leen dentro de Info, así que ambos se capturan y obtienen campos de respaldo con nombres impronunciables, generados por el compilador, de la forma <parámetro>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'       // captura log
  ldarg.0  ldarg.2  stfld  string Logger::'<prefix>P'  // captura prefix
  ret

Info luego lee this.<log>P y this.<prefix>P. Si un parámetro se usara solo para inicializar un campo o propiedad — public string Prefix = prefix; — no obtendría un campo de captura; el constructor lo leería una vez para fijar Prefix y lo descartaría. Los parámetros sin usar no generan nada en absoluto. Y una llamada a la base, class Audited(int id) : Base(id), simplemente pasa el argumento en el constructor sintetizado.

El contraste con los records es el meollo. El constructor primario de un record genera propiedades públicas init-only, igualdad por valor, GetHashCode, ToString/PrintMembers, un método de clonación y Deconstruct — una huella rica y reconocible. Un constructor primario de clase no genera nada de eso. Produce un constructor y, como mucho, algunos campos de captura privados. Ese minimalismo es exactamente por qué es difícil de recuperar: un constructor que asigna parámetros a campos privados readonly es precisamente lo que la gente ha escrito a mano durante décadas.

Así que un decompilador reconstruye class Logger(ILog log, string prefix) solo cuando reconoce la convención de nombres de campo de captura <parámetro>P y la asignación uno a uno de parámetro a campo del constructor. Cuando lo hace, la sintaxis del constructor primario vuelve limpia. Cuando no lo hace — o cuando los campos capturados fueron renombrados por ofuscación, o realmente escribiste el constructor explícito — muestra el equivalente class Logger { private readonly ILog log; … public Logger(ILog log, string prefix) { … } }. Ambas formas compilan al mismo IL, así que el renderizado explícito no es un fallo; es la lectura fiel cuando falta el único marcador distintivo del azúcar.

Qué llevarte de la vista decompilada

Ambas características refuerzan la misma lección sobre leer .NET compilado: la sintaxis de superficie es una elección sobre el IL, no una propiedad de él. Una expresión de colección es cualquiera de cuatro reducciones que exigió su destino, y solo algunas de ellas cargan una huella lo bastante fuerte para reconstruir los corchetes; hacia List<T> es literalmente indistinguible de un inicializador de colección. Un constructor primario de clase es un constructor más campos de captura llamados <x>P, sin nada de la maquinaria de record que hace a los records fáciles de detectar. Un buen decompilador repliega lo que puede probar y muestra el código reducido donde no puede — y la vista de IL es donde confirmas qué ocurrió. Abre el ensamblado en Glass.NET, cambia un método a IL y observa [1, 2, 3] convertirse en un newarr o en un búfer de inline array. Para el flujo completo, consulta cómo decompilar una DLL de .NET a C#.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.