Decompilar records de C#: los miembros generados que los delatan
Un record es una clase más un conjunto preciso de miembros generados por el compilador — igualdad por valor, un método de clonación, un Deconstruct, un PrintMembers/ToString, setters init-only. Un decompilador reconoce esa huella y reconstruye la palabra clave record. Aquí tienes exactamente qué emite el compilador para un record y cómo se lee de vuelta el patrón.
Los records de C# parecen una característica del lenguaje, pero en el ensamblado compilado no hay marcador «record» — un record es una clase corriente con un conjunto muy específico de miembros generados atornillados. Ese conjunto es lo bastante distintivo como para ser una huella, y reconocerla es cómo un decompilador convierte un montón de métodos sintetizados de vuelta en la única palabra record. Recorrer lo que el compilador realmente emite explica tanto el reconocimiento como el comportamiento que obtienes gratis al declarar uno.
En qué se expande un record posicional
Toma el canónico de una línea:
public record Point(int X, int Y);
Eso compila a una clase completa. Decompilado sin reconocimiento de record, es aproximadamente:
public class Point : IEquatable<Point>
{
public int X { get; init; } // init-only, from the primary ctor
public int Y { get; init; }
public Point(int X, int Y) { this.X = X; this.Y = Y; }
protected virtual Type EqualityContract => typeof(Point);
public override bool Equals(object obj) => Equals(obj as Point);
public virtual bool Equals(Point other) =>
other is not null && EqualityContract == other.EqualityContract
&& EqualityComparer<int>.Default.Equals(X, other.X)
&& EqualityComparer<int>.Default.Equals(Y, other.Y);
public override int GetHashCode() => /* combine EqualityContract, X, Y */;
public static bool operator ==(Point a, Point b) => /* ... */;
public static bool operator !=(Point a, Point b) => !(a == b);
protected Point(Point original) { X = original.X; Y = original.Y; } // copy ctor
public virtual Point <Clone>$() => new Point(this); // clone
public override string ToString() { /* uses PrintMembers */ }
protected virtual bool PrintMembers(StringBuilder builder) { /* X = .., Y = .. */ }
public void Deconstruct(out int X, out int Y) { X = this.X; Y = this.Y; }
}
Una línea de código fuente, una docena de miembros generados. Cada uno de ellos es comportamiento que de otro modo escribirías a mano, y cada uno es una pista.
La huella que un decompilador usa como clave
Un decompilador no adivina a partir de «esta clase tiene un Equals». Busca el conjunto específico y co-ocurrente que solo un record produce:
EqualityContract— una propiedadprotected virtual Typeque devuelve el tipo. Nada más que un record emite esto, así que es la señal única más fuerte. (En un record sellado o derivado esprotectedoprivate protectedsegún corresponda, lo que también le dice al decompilador sobre la herencia del record.)<Clone>$— un método de clonación con un nombre impronunciable en C# (el$no puede aparecer en un identificador de código). Existe únicamente para implementar la expresiónwith. Su presencia es inequívoca.- Igualdad por valor —
IEquatable<T>.Equals(T), unEquals(object)sobrescrito,GetHashCode, y los operadores==/!=, todos generados por el compilador y todos consistentes entre sí. PrintMembers+ToString— unprotected virtual bool PrintMembers(StringBuilder)emparejado con unToStringque lo llama. El nombre y la firma dePrintMembersson una convención específica de records.Deconstruct— presente cuando el record es posicional (declarado con una lista de parámetros). Sus parámetros out se alinean con los parámetros del constructor primario, que es cómo el decompilador sabe escribirrecord Point(int X, int Y)en vez de un record con propiedades declaradas por separado.
Cuando el conjunto completo está presente y lleva los marcadores de generado-por-compilador, el decompilador lo colapsa a una línea. Cuando solo hay parte de él — digamos que alguien escribió a mano un Equals y falta un método con forma de <Clone>$ — no finge; muestra una clase. Esa honestidad importa: la palabra clave record es una afirmación sobre semántica generada, y un decompilador solo debería hacer la afirmación cuando la evidencia es completa.
Propiedades init-only y el truco del modreq
Las propiedades X e Y de arriba son init, no set, y eso también vive en el IL en una forma reconocible. Un setter init-only es un setter corriente cuya firma lleva un modificador requerido (modreq) que referencia System.Runtime.CompilerServices.IsExternalInit:
.method public hidebysig specialname instance void
set_X(int32 'value') ...
// the return type is: void modreq(IsExternalInit)
Ese modreq es todo el mecanismo: el compilador se niega a llamar al setter fuera de un inicializador de objeto porque los llamadores deben entender el modificador, y los compiladores antiguos que no lo hacen lo rechazarán. Un decompilador lee el modreq y renderiza init;. Es también una señal corroborante para el reconocimiento de records — los parámetros de un record posicional se vuelven propiedades init-only, así que un grupo de setters init junto al patrón EqualityContract/Deconstruct refuerza que esto es un record posicional en vez de una clase que resulta tener igualdad por valor.
with, record struct y herencia
Tres detalles más aparecen en la forma decompilada. La expresión with compila a una llamada a <Clone>$ seguida de asignaciones de propiedad init-only en el clon — así que cuando ves <Clone>$ invocado y luego unas llamadas set_/init, eso es un with en el original. record struct sigue el mismo guion menos el método de clonación y la maquinaria de herencia (los structs no heredan), así que la huella está libre de EqualityContract pero aún tiene el conjunto generado de igualdad por valor y PrintMembers, y un decompilador lo distingue de un struct llano por exactamente esos miembros. La herencia de records deja su propia traza: el Equals de un record derivado compara EqualityContract, su PrintMembers llama a base.PrintMembers, y el método de clonación es covariante — todo lo cual le permite al decompilador reconstruir la relación : BaseRecord en vez de aplanarla.
Por qué leerlo de vuelta
Casi siempre quieres la palabra clave record, no la docena de miembros expandidos — es lo que escribiste y lo que quieres decir, y Glass.NET lo reconstruye por defecto. Pero la expansión de debajo explica comportamientos que sorprenden. La igualdad por valor es estructural sobre los miembros declarados, que es por qué dos records con campos iguales son Equals aun siendo objetos distintos — y por qué añadir una propiedad de colección mutable te da una igualdad que ignora el contenido de la colección, visible de inmediato en cuanto ves que el Equals generado solo compara las referencias de los miembros. La expresión with es un clon superficial, visible como la llamada a <Clone>$ que copia referencias, no estructura profunda. Y que EqualityContract sea parte de la igualdad es exactamente por qué un record base y uno derivado nunca son iguales ni siquiera con campos idénticos — los tipos de contrato difieren. Leer los miembros generados es cómo esos comportamientos dejan de ser folclore y se vuelven algo que puedes ver. Un record es sintaxis; su semántica es un conjunto específico de métodos, y una vez que puedes reconocer el conjunto, la vista decompilada y la vista del código fuente cuentan la misma historia.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.