Decompilar argumentos opcionales y con nombre: los valores predeterminados en la tabla Constant y los temporales que el compilador emite para reordenar
Un parámetro opcional no tiene coste en tiempo de ejecución en el sitio de llamada que usted escribió: el valor predeterminado no se almacena en el método, el compilador lo sustituye en cada llamador. Así que en IL no hay rastro de un argumento predeterminado salvo en la llamada: un literal apilado donde usted no escribió nada. El propio valor predeterminado vive lejos, en la tabla de metadatos Constant asociada al parámetro, junto a una marca Optional; un decompilador que lea el método tiene que ir a buscarlo allí para imprimir = 0 o = null. Los argumentos con nombre son aún más extraños: desaparecen por completo cuando están en orden, y cuando están desordenados el compilador los reescribe como pushes posicionales en orden, a menos que evaluarlos fuera de orden tenga efectos secundarios, en cuyo caso vuelca primero cada argumento en un temporal para preservar la evaluación de izquierda a derecha, dejando un bloque de almacenamientos que parece código que usted escribió. Este artículo lee el IL y los metadatos de ambos, muestra las filas de la tabla Constant que hay detrás de un valor predeterminado, recorre los temporales de reordenación y explica cómo Glass.NET recupera los valores predeterminados opcionales y decide cuándo merece la pena imprimir un argumento con nombre.
La mayoría de las características de C# dejan en IL una forma que se puede leer de vuelta. Los argumentos opcionales y con nombre son inusuales porque la parte interesante es la ausencia: un argumento opcional que omite, un nombre que escribe. Ninguno sobrevive como tal. El valor predeterminado queda archivado en una tabla de metadatos que el cuerpo del método nunca menciona, y el nombre desaparece en el momento en que el compilador lo ha emparejado con un parámetro. Lo que un decompilador recupera, y lo que admite discretamente que no puede recuperar, convierte estas dos características en una buena lección sobre dónde la compilación pierde información.
Este artículo lee los metadatos y el IL de ambas, y muestra cómo Glass.NET maneja cada una.
Un parámetro opcional: el valor predeterminado vive en la tabla Constant
Aquí tiene un método con dos parámetros opcionales y una llamada que los omite:
public static string Format(string value, int width = 0, char pad = ' ')
=> value.PadLeft(width, pad);
static void Use() => Format("hi"); // width and pad omitted
Los valores predeterminados no están en ninguna parte del cuerpo de Format: Format simplemente usa width y pad como parámetros. Viven en los metadatos de los propios parámetros. En la representación de ILDasm:
.method public hidebysig static string Format(string value,
[opt] int32 width,
[opt] char pad) cil managed
{
.param [2] = int32(0x00000000)
.param [3] = char(0x0020)
...
}
[opt] es la marca Optional de cada parámetro, y .param [2] = int32(0x00000000) es la forma en que ILDasm imprime una fila de la tabla de metadatos Constant. Esa tabla es una estructura de tres columnas (una etiqueta de tipo, un padre —aquí la fila Param— y un blob con los bytes del literal) y es la misma tabla que respalda los campos const y los miembros de enumeración. La fila del parámetro también lleva la marca HasDefault, que es el puntero que dice «ve a buscar en la tabla Constant la fila cuyo padre soy yo». Así que tres piezas codifican un valor predeterminado: Optional en el parámetro, HasDefault en el parámetro y el valor en la tabla Constant.
Lo crucial es que width = 0 y pad = ' ' aparecen en los metadatos de la firma de Format, no en Use. Así que, para decompilar Format e imprimir su firma, un decompilador debe leer las filas de la tabla Constant de los parámetros 2 y 3, descodificar int32(0) y char(0x0020) y representar int width = 0, char pad = ' '. Es una lectura de metadatos, no una inferencia, y funciona sin PDB porque la tabla Constant forma parte del ensamblado del que depende el propio runtime.
El sitio de llamada: los valores predeterminados se sustituyen, no se referencian
Ahora la mitad sutil. Observe a qué compiló realmente Use:
.method private hidebysig static void Use() cil managed
{
IL_0000: ldstr "hi"
IL_0005: ldc.i4.0 // width — the default, baked in
IL_0006: ldc.i4.s 32 // pad — ' ' is 0x20 = 32, baked in
IL_0008: call string Format(string, int32, char)
IL_000d: pop
IL_000e: ret
}
Usted escribió Format("hi"). El compilador apiló tres argumentos. Los valores predeterminados no se obtuvieron de la tabla Constant en tiempo de ejecución: el compilador los leyó en tiempo de compilación y emitió ldc.i4.0 y ldc.i4.s 32 directamente en Use. La llamada es una llamada de tres argumentos perfectamente ordinaria; nada distingue los dos literales de argumentos que un programador hubiera tecleado.
Esto tiene una consecuencia real que merece la pena ver al decompilar: como el valor predeterminado se copió en el llamador, cambiar el valor predeterminado de Format de 0 a 1 y distribuir una nueva biblioteca no cambia el comportamiento de Use hasta que Use se recompile; sigue apilando ldc.i4.0. Un decompilador que lea solo el llamador antiguo ni siquiera puede saber que el valor predeterminado ha cambiado. Es la clásica regla de «los parámetros opcionales son una característica de compatibilidad de código fuente, no de compatibilidad binaria», y es visible aquí mismo, en el literal incrustado.
Para el decompilador crea una ambigüedad que no puede resolver del todo: ldc.i4.0 donde va width es compatible tanto con Format("hi") como con Format("hi", 0). El IL es idéntico. Glass.NET resuelve el método llamado, ve que los argumentos literales finales coinciden con los valores predeterminados de los parámetros y prefiere el Format("hi") más corto como código fuente más probable; pero conserva un argumento cuando eliminarlo cambiaría la resolución de sobrecargas, y muestra todos los argumentos cuando el método llamado está en un ensamblado que no puede resolver para leer los valores predeterminados. Imprime la llamada que es un código fuente válido para el IL, siendo honesto en que omitido-frente-a-escrito es algo que la compilación borra de verdad.
Argumentos con nombre, en orden: nada en absoluto
Los argumentos con nombre son un mecanismo de emparejamiento en tiempo de compilación. Cuando ya están en el orden de declaración, no dejan rastro alguno:
Format(value: "hi", width: 4, pad: '.');
compila exactamente al mismo ldstr / ldc.i4.4 / ldc.i4.s 46 / call que la forma posicional. Los nombres se usaron solo para asociar cada expresión a un parámetro; una vez asociadas, el compilador emite pushes posicionales. No hay nada en el ensamblado que diga que se escribió un nombre, así que un decompilador no puede reconstruir width: y pad:, ni debería fingir que lo hace. Glass.NET imprime la llamada posicional, que es idéntica en comportamiento.
Argumentos con nombre, fuera de orden: reordenación y, a veces, volcado
El único caso en que los argumentos con nombre tocan la generación de código es cuando se escriben fuera del orden de los parámetros, porque la pila debe construirse de todos modos en el orden de los parámetros. Hay dos subcasos, y tienen un aspecto distinto en IL.
Si cada argumento fuera de orden está libre de efectos secundarios (una constante, un local, una lectura de campo que el compilador considera segura), el compilador simplemente los apila en el orden de los parámetros:
Format(width: 4, value: "hi"); // 'pad' omitted → default
IL_0000: ldstr "hi" // value pushed first — parameter order
IL_0005: ldc.i4.4 // width
IL_0006: ldc.i4.s 32 // pad default
IL_0008: call string Format(string, int32, char)
Observe que el ldstr "hi" va primero aunque width: 4 se escribió primero: el orden observable no importa para una constante y un literal, así que el compilador reordena discretamente al orden de los parámetros y la forma del código fuente es irrecuperable.
Pero cuando evaluar fuera del orden escrito sería observable (un efecto secundario, un getter, un incremento), la garantía de evaluación de izquierda a derecha de C# obliga al compilador a volcar en temporales:
int n = 0;
Format(width: Next(ref n), value: Read(ref n)); // both have side effects, written out of order
.locals init ([0] int32 n, [1] int32 V_1, [2] string V_2)
IL_...: ldloca.s n
IL_...: call int32 Next(int32&)
IL_...: stloc.1 // V_1 = Next(ref n) — evaluated FIRST (written first)
IL_...: ldloca.s n
IL_...: call string Read(int32&)
IL_...: stloc.2 // V_2 = Read(ref n) — evaluated SECOND
IL_...: ldloc.2 // now push in PARAMETER order: value …
IL_...: ldloc.1 // … then width
IL_...: ldc.i4.s 32 // pad default
IL_...: call string Format(string, int32, char)
Los dos temporales V_1 y V_2 son la huella. Se crean en el orden escrito (Next primero, porque width: se escribió primero) y se consumen en el orden de los parámetros (value primero en la pila). Ese cruce (evaluar en un orden, apilar en otro) es la única evidencia de que los argumentos con nombre se reordenaron. Y no basta para reconstruir los nombres: exactamente el mismo patrón de volcado en temporales es el que emite el compilador para la evaluación ordinaria de izquierda a derecha de argumentos posicionales que resultan necesitar secuenciación, y un programador podría haber escrito esos temporales a mano. Así que Glass.NET representa el volcado fielmente como temporales y una llamada posicional, en lugar de aventurar una sintaxis width:/value: que el IL en realidad no determina.
Lo que Glass.NET imprime, y lo que no se inventará
Juntando las dos características, la recuperación se divide limpiamente según lo que realmente contienen los metadatos:
- Los valores predeterminados opcionales de una firma se recuperan exactamente, porque están en la tabla Constant: Glass.NET lee las marcas
HasDefault/Optionaly la fila Constant de cada parámetro e imprimeint width = 0, char pad = ' 'con el literal descodificado según su etiqueta de tipo:char(0x0020)como' ', un valor predeterminado de enumeración como el miembro con nombre, un structdefaultcomodefault. - Los argumentos omitidos en una llamada se recuperan resolviendo el método llamado y comparando los literales finales con los valores predeterminados, prefiriendo después la forma más corta que siga enlazando con la misma sobrecarga, y recurriendo a mostrar todos los argumentos cuando el método llamado no puede leerse o cuando una omisión cambiaría la resolución de sobrecargas.
- Los nombres de los argumentos con nombre no se inventan. Cuando el orden de los pushes coincide con el orden de los parámetros, la llamada se imprime de forma posicional. Cuando un volcado en temporales muestra un cruce entre orden escrito y orden de parámetros, los temporales se muestran como lo que son y la llamada sigue siendo posicional. Los nombres se borraron en tiempo de compilación, y un decompilador que fabricara
width:/value:estaría disfrazando una conjetura de hecho.
Ese último punto es el tema que ambas características comparten. La decompilación recupera lo que el compilador almacenó, y para los argumentos opcionales y con nombre el compilador almacenó el valor predeterminado (en una tabla que se puede leer) pero desechó el nombre y el «esto lo omitiste» (sustituyéndolo por un literal que no se puede distinguir). Un buen decompilador traza esa línea a la vista de todos: imprime el valor predeterminado porque realmente está ahí, y se niega a reconstruir el nombre porque realmente no lo está.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.