Descompilar [CallerArgumentExpression]: el texto fuente que el compilador incrusta en tus llamadas, y cómo Glass restaura la llamada limpia
Cuando llamas a ArgumentNullException.ThrowIfNull(x) o a un Guard.Against(x) propio, pasas un argumento. El IL compilado empuja dos: tu valor y un literal de cadena con el texto fuente exacto de tu primer argumento, porque el parámetro lleva [CallerArgumentExpression]. El atributo vive en los metadatos del parámetro; el texto capturado vive como un simple ldstr en cada llamada. Un descompilador ingenuo te devuelve el segundo argumento redundante. Este artículo lee el IL y el blob del atributo, explica por qué está ahí el literal y recorre cómo Glass.NET detecta el atributo, compara el literal con la expresión reconstruida, suprime el argumento sintetizado y reconstruye la declaración del método con el atributo intacto.
La mayoría de las características del compilador que tocan una llamada dejan una forma que puedes leer de vuelta: una máquina de estados, una clase de visualización, un temporal oculto. [CallerArgumentExpression] es inusual porque lo que deja atrás es una cadena de tu propio código fuente, congelada en los argumentos del método llamado en el momento en que compilaste. Descompila un proyecto que usa ArgumentNullException.ThrowIfNull o un Guard artesanal y, sin el tratamiento adecuado, recibes los nombres de tus propios argumentos devueltos como cadenas literales. Este artículo lee exactamente lo que emite el compilador y muestra cómo Glass.NET detecta el atributo, compara el literal y restaura la llamada que realmente escribiste.
Aquí la descompilación es la clase corriente y legítima: leer IL para entender, depurar o auditar código que escribiste o que tienes derecho a inspeccionar.
La característica y en qué compila
[CallerArgumentExpression] permite que un parámetro reciba, como cadena, la expresión textual que un llamador pasó para otro argumento. El uso canónico está en la BCL:
public static void ThrowIfNull(
[NotNull] object? argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
if (argument is null)
throw new ArgumentNullException(paramName);
}
Llámalo con un solo argumento:
static void Use(Order order) => ArgumentNullException.ThrowIfNull(order.Customer);
Pasaste un argumento. El compilador emite dos. El segundo es el texto fuente del primero, capturado literalmente:
.method private hidebysig static void Use(class Order order) cil managed
{
IL_0000: ldarg.0
IL_0001: callvirt instance class Customer Order::get_Customer()
IL_0006: ldstr "order.Customer" // <- la caller-expression incrustada
IL_000b: call void [System.Runtime]System.ArgumentNullException::ThrowIfNull(object, string)
IL_0010: ret
}
Ese ldstr "order.Customer" es toda la característica en la llamada. No se calcula en tiempo de ejecución, no es una referencia a ningún símbolo: es una instantánea congelada de los caracteres que escribiste entre paréntesis, producida por el compilador leyendo su propio árbol sintáctico y renderizando a texto el argumento referenciado. Cambia el espaciado o la ruta del miembro en el fuente, recompila, y el literal cambia con él. Publica un llamador nuevo sin recompilar y el literal antiguo persiste, porque el texto se copió en el llamador, no se referenció desde el método llamado.
Dónde vive el atributo en los metadatos
La llamada solo cuenta la mitad de la historia. La indicación de que es un argumento de expresión del llamador —en lugar de una cadena corriente que el autor pasó— vive sobre el parámetro del método llamado, en los metadatos:
.method public hidebysig static void ThrowIfNull(object argument,
[opt] string paramName) cil managed
{
.param [2]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CallerArgumentExpressionAttribute::.ctor(string)
= ( 01 00 08 61 72 67 75 6D 65 6E 74 00 00 ) // ...argument..
.param [2] = nullref
...
}
En el parámetro 2 se codifican tres hechos. La marca [opt] lo señala como Optional. El .param [2] = nullref es su valor predeterminado en la tabla Constant: un parámetro de expresión del llamador siempre es opcional con un valor predeterminado, de modo que los llamadores que no quieren la captura (o que apuntan a un compilador más antiguo) siguen enlazando. Y la fila .custom es el propio CallerArgumentExpressionAttribute. Decodifica su blob y obtienes el argumento del constructor: 01 00 es el prólogo del atributo, 08 es la longitud de la cadena UTF-8 que sigue, 61 72 67 75 6D 65 6E 74 deletrea argument, y el 00 00 final es el recuento (cero) de propiedades con nombre. Así que el atributo dice, en efecto: “cuando un llamador me omita, relléname con el texto fuente del parámetro llamado argument.”
Ese es el puntero que un descompilador debe seguir. El literal en la llamada no significa nada por sí solo: solo el atributo del parámetro 2 te dice que la cadena es una captura sintetizada del argumento 1 y, por tanto, candidata a la supresión.
Qué imprime un descompilador ingenuo
Un descompilador que recorre la llamada y lee solo los operandos ve una invocación de dos argumentos perfectamente corriente. No tiene motivo para tratar el segundo operando como especial, así que imprime ambos:
// Salida ingenua — técnicamente fiel, pero ruidosa
ArgumentNullException.ThrowIfNull(order.Customer, "order.Customer");
Esto no está mal —recompilarlo produce IL idéntico, porque un argumento explícito siempre gana sobre un valor predeterminado de expresión del llamador. Pero no es lo que nadie escribió, y resulta activamente engañoso: sugiere que al autor le importaba esa cadena, cuando en realidad el compilador la sintetizó. Multiplica esto por una base de código que se apoya en ThrowIfNull, ArgumentException.ThrowIfNullOrEmpty y una biblioteca de guardas propia, y cada cláusula de guarda carga un eco redundante de su primer argumento. La salida se lee como código escrito por alguien que no confiaba en la característica.
Cómo detecta Glass y suprime el argumento sintetizado
Glass.NET trata la llamada y los metadatos del método llamado como dos mitades de un mismo hecho. La recuperación corre en cuatro pasos, que se muestran a continuación.
Primero, resolver el método llamado y leer los metadatos de sus parámetros. Glass descubre que el parámetro 2 de ThrowIfNull lleva CallerArgumentExpressionAttribute("argument"), así que la cadena en la posición 2 es un candidato a argumento sintetizado ligado a la posición 1.
Segundo, reconstruir la expresión referenciada. Glass ya tiene un árbol sintáctico recuperado para la llamada: sabe que el argumento 1 es order.Customer. Renderiza ese argumento a texto C# canónico exactamente como lo habría hecho el compilador: acceso a miembros, indexadores, conversiones y literales vuelven todos a la misma ortografía que usó el compilador al crear la cadena incrustada.
Tercero, normalizar y comparar. El compilador captura el texto fuente incluyendo cualesquiera espacios en blanco insignificantes que usara el autor (ThrowIfNull( order . Customer ) incrustaría "order . Customer"), mientras que el texto regenerado por Glass es canónico. Por eso el comparador normaliza ambos lados respecto a los espacios en blanco insignificantes antes de comparar. Cuando las cadenas normalizadas son iguales, el literal es obra del propio compilador.
Cuarto, suprimir o conservar. Si coinciden, Glass descarta el argumento e imprime la llamada limpia:
// Salida de Glass.NET
ArgumentNullException.ThrowIfNull(order.Customer);
Si no coinciden —el autor escribió ThrowIfNull(order.Customer, "the customer"), anulando deliberadamente la captura, o el primer argumento es algo cuyo texto Glass no puede fijar— el literal se conserva, porque descartarlo cambiaría el significado. La regla es conservadora por diseño: Glass elimina el argumento solo cuando puede probar que el literal es exactamente lo que la característica regeneraría.
Reconstruir la declaración
Suprimir la llamada es solo la mitad de una descompilación fiel; la declaración del método tiene que volver a llevar el atributo, o recompilar la salida no reproduciría la captura. Glass lee la fila .custom del parámetro 2, decodifica la cadena de constructor con prefijo de longitud (argument), lee la marca Optional y el valor predeterminado nullref de la tabla Constant, y vuelve a emitir los tres:
public static void ThrowIfNull(
object? argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
Glass prefiere nameof(argument) a una cadena "argument" pelada cuando un parámetro con ese nombre exacto está en ámbito, para coincidir con el fuente idiomático; recurre a la cadena literal si no existe tal parámetro (por ejemplo, si el nombre referenciado no se resuelve, cosa que el compilador de C# permite). El valor predeterminado = null y la anotación anulable string? vienen directamente de la tabla Constant y del NullableAttribute respectivamente, así que la firma reconstruida es recompilable tal cual.
Probarlo con la CLI
La CLI glass hace visible el antes y el después. Apúntala a un ensamblado y a un tipo, e imprime C# limpio por defecto:
glass decompile MyApp.dll --type MyApp.Services.OrderValidator
Para ver la forma cruda, sin limpiar —cada argumento sintetizado dejado en su sitio— desactiva la pasada de simplificación:
glass decompile MyApp.dll --type MyApp.Services.OrderValidator --no-sugar
Ejecutar ambos contra un validador lleno de cláusulas de guarda es la forma más rápida de ver lo que la característica cuesta realmente en la llamada: con --no-sugar cada guarda muestra su literal repetido; con la pasada por defecto, cada una se colapsa de vuelta a la llamada de un solo argumento que escribiste. Y descompilar el propio tipo de guarda muestra el atributo restaurado sobre el parámetro, de modo que puedes leer el contrato de captura directamente.
La conclusión
[CallerArgumentExpression] ilustra con nitidez un principio general de los descompiladores: el IL es fiel pero está subespecificado. El literal en la llamada es real y recompilable, pero no es fuente que nadie escribiera: es una copia en tiempo de compilación del fuente, y solo los metadatos del parámetro del método llamado lo revelan. Un descompilador que lee uno sin el otro te da ruido; uno que los une te devuelve la llamada que escribiste. Glass.NET traza la línea con precisión: suprime el argumento sintetizado solo cuando puede probar que el literal coincide con la expresión que regeneraría, y reconstruye el atributo de la declaración a partir de los metadatos para que el fuente recuperado compile al mismo IL del que provino.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.