Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompilación.NETAnálisis a fondo

Descompilar colecciones params: ¿dónde fue a parar mi array?

Durante veinte años un parámetro params significaba una sola cosa en IL: el llamador construía un newarr y lo rellenaba. C# 13 generalizó params a cualquier tipo de colección, y el caso estrella — params ReadOnlySpan<T> — no asigna absolutamente nada en el heap. El llamador apila los argumentos en un búfer inline-array generado por el compilador y envuelve un span a su alrededor; params List<T> pasa en cambio por un collection builder. La pista para descompilar está enteramente en el sitio de llamada, no en la firma. Aquí está lo que emite cada forma, cómo un descompilador pliega el búfer de vuelta en una llamada simple, y por qué un params ReadOnlySpan<T> que muestra un newarr es una ruta de respaldo fría que merece atención.

params es la comodidad más antigua de C#: escribe string.Format("{0}+{1}", a, b) y el compilador construye discretamente el object[] que Format recibe en realidad. Durante veinte años ese desazucarado tuvo exactamente una forma — un newarr, una serie de stelem y la llamada — y todos los descompiladores aprendieron a plegarla de vuelta en argumentos sueltos.

C# 13 generalizó params a cualquier tipo de colección, y el miembro interesante de la nueva familia, params ReadOnlySpan<T>, no asigna nada en el heap. Los argumentos van a un búfer asignado en la pila y se envuelve un span a su alrededor. La firma del método apenas cambia; toda la diferencia está en el sitio de llamada. Un descompilador que todavía solo conoce la forma newarr te mostrará un tipo de búfer generado por el compilador que no puedes nombrar y un constructor de span donde el código fuente tenía una llamada simple.

La firma apenas se mueve; el sitio de llamada se mueve mucho

Considera un método de logging migrado de la forma clásica a la forma con span:

// 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) { /* ... */ }

Ambos se llaman de forma idéntica: LogNew(id, name, DateTime.Now). Pero el IL que emite el llamador es completamente distinto. La forma clásica es el conocido array en el heap:

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

La forma con span no asigna ningún array. El compilador emite un tipo inline-array generado por el compilador — una struct que lleva [InlineArray(3)], el mismo mecanismo que C# usa para inline arrays y stackalloc — como un local en el marco del llamador, almacena cada argumento en él a través de los intrínsecos de inline-array, construye un ReadOnlySpan<object> sobre el búfer y lo pasa:

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

Sin newarr, sin objeto en el heap, sin presión sobre el GC. El búfer es un tipo por valor en la pila; muere con el marco. Cada ápice de la diferencia está en el llamador — el destinatario simplemente recibió un span.

Las tres formas que un descompilador debe conocer

params ahora tiene una pequeña familia de reducciones (lowerings), elegidas según el tipo de colección del parámetro. Un descompilador lee el sitio de llamada para distinguirlas y debe plegar todas ellas de vuelta en argumentos sueltos.

params T[]newarr + stelem (heap)params ReadOnlySpan<T>inline-array buffer + spanparams List<T> / IEnumerablecollection builderdecompiles tofoo(a, b, c)

params T[] — la ruta clásica, sin cambios. El tipo del parámetro es un array, así que el llamador emite newarr + stelem + llamada. Un descompilador lo pliega exactamente como siempre lo ha hecho. Nada nuevo aquí, y esa es la cuestión: el código antiguo conserva su IL antiguo.

params ReadOnlySpan<T> / params Span<T> — la ruta sin asignaciones. El búfer <>y__InlineArrayN, los almacenamientos de elementos a través de InlineArrayElementRef, la llamada a InlineArrayAsReadOnlySpan y después la llamada al destino. La regla de reconocimiento es: un local inline-array generado por el compilador, rellenado elemento a elemento y envuelto en un span que alimenta inmediatamente una llamada params, son argumentos sueltos. Renderízalo como LogNew(id, name, now), no como un búfer visible y un constructor de span — el tipo <>y__InlineArray3 no se puede nombrar en código C#, así que emitirlo literalmente produce código que no recompilará.

params List<T> / params IEnumerable<T> — la ruta del collection builder. Para una colección que no es un span, el compilador reutiliza la maquinaria de las expresiones de colección: construye la colección (una List<T> a través de su ruta de añadido, o el método [CollectionBuilder] de un tipo) y pasa la colección terminada. El descompilador debe reconocer la secuencia del builder y plegarla también de vuelta en argumentos sueltos.

Las tres colapsan al mismo código fuente — foo(a, b, c) —, así que el trabajo del descompilador es borrar el mecanismo y recuperar la llamada.

La pista: un span alimentado por newarr

Aquí está el caso sutil que merece la pena detectar. Incluso un parámetro params ReadOnlySpan<T> no recibe siempre el búfer en la pila. El compilador asigna en la pila solo cuando puede demostrar que el span no escapará; en las rutas donde no puede, recurre a un array en el heap y envuelve el span alrededor de ese. Así que de vez en cuando verás:

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

Un newarr alimentando un constructor de ReadOnlySpan, en lugar de un búfer <>y__InlineArray, es la huella de la ruta de respaldo. Sigue siendo una llamada params correcta y sigue plegándose a LogNew(id, name, now) en el código descompilado — pero te dice que esta llamada en concreto sí asignó, que es exactamente lo que la forma con span se eligió para evitar. Cuando lees salida descompilada para entender un perfil de asignaciones, esa distinción lo es todo: la misma línea de código, distinto coste, y solo el IL muestra cuál de las dos obtuviste.

Lo que emite un buen descompilador

Pon las formas una junto a otra y la obligación queda clara. Para la llamada sin asignaciones, un descompilador que se queda a ras del metal muestra:

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

mientras que recuperar la intención del autor produce:

// Right: the call the author actually wrote
LogNew(id, name, DateTime.Now);

Glass.NET pliega las tres formas de params — el array clásico, el span sobre inline-array y las formas con collection builder — de vuelta en una llamada con argumentos sueltos, y mantiene explícito un argumento de span o lista cuando el código fuente realmente pasó uno en lugar de valores sueltos. La característica trasladó el trabajo interesante de la firma del método al sitio de llamada, así que ahí es donde un descompilador tiene que mirar. Recupera la llamada, descarta el búfer y anota la pista de asignación cuando la necesites.

Consulta inline arrays y stackalloc para el mecanismo de búfer que esto reutiliza, y expresiones de colección para la ruta del builder que comparten las formas sin span.

Prueba Nebula.NET

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