Decompilar métodos de extensión: el atributo [Extension] es la única huella, el receptor era solo el primer argumento, y la llamada que parece estática no lo es
Un método de extensión no existe en tiempo de ejecución como extensión. Es un método estático corriente cuyo primer parámetro resulta estar marcado, y una llamada a él —text.Trim().Count()— compila a una llamada estática ordinaria, Enumerable.Count(string.Trim(text)), sin sintaxis de punto, sin receptor, sin nada que diga que alguna vez se escribió de forma fluida. Lo único que el compilador deja atrás es un atributo [Extension]: en el método, para que el primer parámetro pueda representarse con this, y en la clase estática que lo contiene, para que una herramienta sepa siquiera que debe mirar ahí. Un decompilador tiene que leer ese atributo, decidir que una llamada estática cuyo destino lo lleva debe imprimirse como una llamada de miembro sobre su primer argumento, y acertar con la precedencia para que una cadena de diez operadores LINQ vuelva como una cadena y no como un anidamiento de diez niveles de llamadas estáticas. Esta entrada recorre el IL de un método de extensión definido y de un lugar de llamada, muestra qué reconstruye Glass.NET, explica por qué el atributo —y no la llamada— es la fuente de verdad, y cubre la forma más reciente de bloque de extensión de C# 14, cuyos miembros compilan a los mismos métodos estáticos bajo otra piel.
Un método de extensión es uno de los ejemplos más puros de azúcar de C# que se evapora en IL. Escribes this en el primer parámetro, lo llamas como text.WordCount() y sientes que le has añadido un método a string. El runtime no ve nada de eso. Hay un método estático, hay una llamada estática y hay un atributo —[Extension]— dejado atrás como una nota que dice esto podría haberse escrito con un punto. Todo el trabajo de un decompilador aquí consiste en leer esa nota y decidir imprimir el punto, y luego acertar con la precedencia para que un pipeline LINQ de diez operadores vuelva como un pipeline y no como un anidamiento de diez niveles. Esta entrada lo recorre para Glass.NET.
El método: un método estático con un atributo puesto
Empieza por la definición. Una extensión trivial sobre string:
public static class StringExtensions
{
public static int WordCount(this string s)
=> s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}
Compílalo y mira en qué se convirtió this. Ha desaparecido del parámetro —s es un parámetro string corriente— y en su lugar hay dos atributos [Extension], uno en el método y otro en la clase:
.class public abstract sealed auto ansi beforefieldinit StringExtensions
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )
.method public hidebysig static int32 WordCount(string s) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )
// ... ldarg.0; call String::Split(...); ldlen; ...
}
}
Esa es toda la diferencia entre un método de extensión y un helper estático. El método es public static, el primer parámetro es un parámetro normal de un tipo normal, no hay ninguna convención de llamada especial. El atributo en el método es lo que permite a un decompilador representar el primer parámetro como this s; el atributo en la clase es lo que le dice al compilador consumidor que, para empezar, vale la pena escanear la clase en busca de extensiones. Quita ambos y tienes StringExtensions.WordCount(x) y nada más; añádelos y x.WordCount() también pasa a ser legal.
El lugar de la llamada: idéntico byte a byte a una llamada estática
Ahora llámalo de dos maneras y compara el IL:
int a = "the quick brown fox".WordCount(); // fluent
int b = StringExtensions.WordCount("the quick brown fox"); // explicit static
Ambas compilan a las mismas tres instrucciones: cargar la cadena, call al método estático, almacenar:
ldstr "the quick brown fox"
call int32 StringExtensions::WordCount(string)
stloc.0
No hay una segunda forma. El CLR no tiene el concepto de llamada de extensión, así que el this se resuelve y se descarta en tiempo de compilación, y el lugar de la llamada no conserva ningún registro de qué sintaxis usó el autor. Este es el quid de todo el problema: un decompilador no puede saber por la llamada si el código fuente era fluido. Solo puede mirar el método que se está llamando, ver [Extension] en él y elegir imprimir la llamada como un acceso a miembro sobre su primer argumento —"the quick brown fox".WordCount()— porque esa es la forma idiomática y casi siempre la original. Ambas representaciones son legales y compilan de forma idéntica, así que la elección es de legibilidad, no de corrección. Glass.NET la toma, y por eso la salida se lee como C# escrito por una persona y no como la expansión de una macro.
La cadena: una regla, aplicada hacia fuera
La razón por la que el punto importa es LINQ, donde leer el IL literalmente produce algo correcto e ilegible. Toma un pipeline:
var top = people.Where(p => p.Active).Select(p => p.Name).OrderBy(n => n).ToList();
Cada uno de Where, Select, OrderBy, ToList es un método de extensión estático en Enumerable, así que el IL es un anidamiento: la llamada más externa se emite la última, y cada resultado alimenta el primer argumento de la siguiente llamada:
call ...ToList( call ...OrderBy( call ...Select( call ...Where(people, <lambda>), <lambda>), <lambda>))
Léelo literalmente y obtienes Enumerable.ToList(Enumerable.OrderBy(Enumerable.Select(Enumerable.Where(people, …), …), …)): un anidamiento de cuatro niveles que tienes que leer de dentro hacia fuera. Como cada método lleva [Extension], Glass.NET aplica esa única regla repetidamente: imprimir cada llamada estática como un acceso a miembro sobre su primer argumento. El Where(people, …) más interno se convierte en people.Where(…); esa expresión completa es el primer argumento de Select, así que se lee people.Where(…).Select(…); y así hacia fuera hasta que el anidamiento se ha desenrollado en la cadena original. La precedencia tiene que ser exacta —el cuerpo de una lambda con un condicional, o una conversión insertada a mitad de la cadena, necesita los paréntesis correctos o el significado cambia—, pero la forma sale directamente de aplicar «acceso a miembro sobre el primer argumento» desde la llamada más interna hacia fuera.
Por qué el atributo, y no la llamada, es la fuente de verdad
Vale la pena ser preciso sobre en qué confía Glass. El lugar de la llamada es ambiguo por construcción —es el mismo IL para ambas sintaxis—, así que no aporta ninguna señal. Los metadatos del método son inequívocos: [Extension] está presente o no lo está. Así que Glass basa su decisión en el método:
- El método destino lleva
[Extension]→ representar la llamada comoarg0.Method(rest). - El método destino no lo lleva → representarla como
Type.Method(args).
Esto también maneja correctamente los casos reales incómodos. Si alguien escribió deliberadamente Enumerable.Where(list, p) en forma estática explícita, Glass sigue imprimiendo list.Where(p), lo cual está bien, porque compila de vuelta al mismo IL y se lee mejor. Y un método de extensión genérico como Select<TSource, TResult> lleva el atributo exactamente igual; Glass representa src.Select(f) y deja que la inferencia de tipos de C# recupere los argumentos de tipo al recompilar, deletreándolos solo cuando la inferencia no puede. El atributo es una señal fiable y local en el método que se está llamando, que es exactamente lo que un decompilador quiere: sin análisis global, sin adivinar a partir de directivas using del entorno que ni siquiera están en el IL.
La forma de bloque de extensión de C# 14
El código más reciente puede usar bloques de extensión, que agrupan miembros bajo un único receptor y, por primera vez, permiten propiedades de extensión:
public static class StringExtensions
{
extension(string s)
{
public int WordCount => s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
public bool IsBlank() => string.IsNullOrWhiteSpace(s);
}
}
Es sintaxis nueva, no runtime nuevo. Los miembros siguen compilando a métodos estáticos en la clase estática que los contiene, marcados para que el compilador de un consumidor pueda encaminar name.WordCount y name.IsBlank() hacia ellos: una propiedad de extensión se convierte en un método de acceso estático, un método de extensión en un método estático con el receptor como primer parámetro, más metadatos de agrupación que vinculan varios miembros a un mismo receptor. Un decompilador que lee un ensamblado así ve métodos estáticos con metadatos marcadores y un parámetro receptor, más o menos como antes. Glass.NET los reconstruye como extensiones; un método de extensión corriente puede mostrarse en la forma clásica con parámetro this o dentro de un bloque, pero una propiedad de extensión no tiene escritura clásica, así que vuelve como un bloque. La semántica es idéntica en cualquier caso, porque los métodos estáticos resultantes son idénticos: cambió la piel, no el IL.
La conclusión
Un método de extensión deja exactamente una huella en los metadatos: el atributo [Extension], en el método para que el primer parámetro pueda llevar this, y en la clase para que un compilador sepa que debe mirar ahí. El lugar de la llamada no deja ninguna huella —text.WordCount() y StringExtensions.WordCount(text) son IL idéntico byte a byte—, así que un decompilador no puede deducir la forma fluida de la llamada y debe leerla del método. Glass.NET hace exactamente eso: imprime cualquier llamada estática cuyo destino lleve [Extension] como un acceso a miembro sobre el primer argumento, aplica esa única regla desde la llamada más interna hacia fuera para desenrollar un anidamiento LINQ de vuelta a una cadena, y reconoce que los bloques de extensión de C# 14 compilan a los mismos métodos estáticos bajo una sintaxis más nueva. El resultado se lee como el pipeline que se escribió, no como el anidamiento que dejó el compilador, porque el decompilador confió en el atributo, que es lo único que sobrevivió.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.