Decompilar setters init-only y miembros required: el modreq y los atributos que los forman
init y required son dos de las palabras clave más pequeñas de C# y dos de las más fáciles de perder para un decompilador. Un accesor init no es más que un accesor set cuya firma lleva un modificador requerido —modreq(IsExternalInit)— y un decompilador que no mira el modificador lo muestra como un set ordinario, entregándote en silencio un tipo cuyas propiedades pueden reasignarse tras la construcción. required es peor: son tres atributos distintos trabajando juntos —RequiredMemberAttribute en cada miembro, RequiredMemberAttribute en el tipo y CompilerFeatureRequired en el tipo— más un SetsRequiredMembers en cualquier constructor que los satisfaga, y un decompilador que los descarta produce código cuyos llamadores ya no están obligados a asignar nada. Esta entrada lee el IL y los metadatos de ambas funcionalidades, muestra la forma exacta a nivel de byte del modreq y de los blobs de atributos, recorre cómo Glass.NET reconstruye init, required y [SetsRequiredMembers], y explica por qué saltarse el paso cambia en silencio el contrato público de un tipo.
init y required son dos de las palabras clave más cortas del C# moderno, y dos de las más fáciles de tirar para un decompilador sin darse cuenta.
Ambas son funcionalidades solo de tiempo de compilación. El runtime no fuerza ninguna de las dos: un setter por reflexión reasignará felizmente una propiedad init, y nada te impide construir un objeto que se salte un miembro required si rodeas al compilador. Ambas existen enteramente para que el compilador pueda atar a los llamadores a un contrato: pon esto una vez, durante la construcción; debes poner esto antes de tener un objeto válido. Ese contrato está codificado en metadatos que el compilador lee y todos los demás ignoran, lo que significa que un decompilador tiene que ir a leer los mismos metadatos o te entregará un fuente cuyo contrato es en silencio más débil que el original.
Las señales son pequeñas. init es un modificador personalizado en un setter. required son tres atributos más, en el constructor que satisface, un cuarto. Pásalos por alto y el código todavía compila: eso es exactamente lo que lo hace peligroso.
init es un modreq en el setter
Toma el ejemplo más pequeño posible y mira lo que emite el compilador:
public class Person
{
public string Name { get; init; }
}
La propiedad compila a un getter y un setter como cualquier otra, pero la firma del setter no es del todo ordinaria. En IL:
.property instance string Name()
{
.get instance string Person::get_Name()
.set instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) Person::set_Name(string)
}
.method public hidebysig specialname instance void
set_Name(string 'value') cil managed
{
// ... ordinary field store ...
}
Toda la diferencia entre set e init es el modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) en el tipo de retorno de set_Name. Un modificador personalizado es parte de la firma —el CLR trata una firma con un modreq como distinta de una sin él— y el compilador usa este en concreto como centinela. Cuando resuelve una llamada a un setter y ve IsExternalInit en la firma, permite la llamada solo desde un contexto de inicialización: el constructor del tipo declarante, un inicializador de objeto o una expresión with. En cualquier otro sitio es un error de compilación. El runtime no fuerza nada; IsExternalInit es solo un tipo que el compilador acordó tratar como bandera.
Eso hace preciso el trabajo del decompilador. Lee la firma del setter; si el retorno lleva modreq(IsExternalInit), renderiza init; si no, renderiza set. Un decompilador que recorre solo el cuerpo del método y el par get/set de la propiedad, sin inspeccionar los modificadores personalizados de la firma del setter, pierde la distinción y emite set. La salida compila —esa es la trampa— pero el Person regenerado deja ahora que cualquier llamador escriba p.Name = "..." mucho después de la construcción, una mutación que el init del autor estaba ahí específicamente para prohibir.
Glass.NET lee el modreq de la firma del setter y reconstruye la palabra clave:
// Decompiled by Glass.NET — the init is recovered from modreq(IsExternalInit)
public class Person
{
public string Name { get; init; }
}
required son tres atributos que deben viajar juntos
Ahora añade la otra palabra clave:
public class Person
{
public required string Name { get; init; }
public required string Email { get; init; }
}
No hay IL nuevo para los cuerpos de las propiedades: required no genera código alguno. Genera atributos, y tres distintos, porque tres cosas distintas necesitan marcarse. Decompilado de los metadatos, el tipo lleva:
.class public auto ansi beforefieldinit Person
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.RequiredMemberAttribute::.ctor() = ( 01 00 00 00 )
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string) = ( 01 00 0F 52 65 71 75 69 72 65 64 4D 65 6D 62 65 72 73 00 00 )
// "RequiredMembers"
// and on each required property:
.property instance string Name()
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.RequiredMemberAttribute::.ctor() = ( 01 00 00 00 )
// ...
}
}
El blob del atributo en CompilerFeatureRequired merece decodificarse, porque es la guarda de fallar en cerrado. 01 00 es el prólogo; 0F es el prefijo de longitud (15) de la cadena UTF-8 que sigue, 52 65 71 75 69 72 65 64 4D 65 6D 62 65 72 73 = "RequiredMembers"; 00 00 es el recuento (vacío) de argumentos con nombre. Los tres atributos hacen tres trabajos:
RequiredMemberAttributeen cada miembro marca qué miembros son requeridos. Son los que un compilador consumidor obliga a los llamadores a asignar.RequiredMemberAttributeen el tipo es un marcador rápido de que el tipo tiene miembros requeridos, para que un consumidor no tenga que escanear cada miembro para averiguarlo.CompilerFeatureRequiredAttribute("RequiredMembers")en el tipo es la guarda. Le dice a un compilador que no entiende los miembros requeridos que rechace el tipo en lugar de ignorar el requisito: el equivalente, para una característica del lenguaje, de fallar en cerrado. Sin él, un compilador antiguo dejaría felizmente a los llamadores omitir miembros que el autor exigía.
Un decompilador que filtra estos como “ruido generado por el compilador” —un valor por defecto que suena razonable, ya que no quieres [Nullable] ni [IsReadOnly] ensuciando tu salida— elimina el contrato required entero. El tipo regenerado tiene propiedades opcionales ordinarias, y cada llamador al que el original obligaba a escribir new Person { Name = ..., Email = ... } es ahora libre de escribir new Person { }. El contrato no se debilitó a gritos; simplemente desapareció.
Glass.NET reconoce el emparejamiento RequiredMemberAttribute/CompilerFeatureRequired como la codificación de required, consume los atributos y vuelve a emitir la palabra clave en exactamente los miembros que llevaban el atributo a nivel de miembro:
public class Person
{
public required string Name { get; init; }
public required string Email { get; init; }
}
[SetsRequiredMembers] es portador en los constructores
Hay un cuarto atributo, y vive en los constructores. Considera un tipo que ofrece un constructor que asigna todo lo requerido:
public class Person
{
public required string Name { get; init; }
public required string Email { get; init; }
[SetsRequiredMembers]
public Person(string name, string email)
{
Name = name;
Email = email;
}
}
[SetsRequiredMembers] le dice al compilador que este constructor ya satisface cada miembro requerido, de modo que los llamadores que lo usan no están obligados a volver a poner esos miembros en un inicializador de objeto. Sin él, new Person("a", "b") seguiría exigiendo { Name = ..., Email = ... }, lo cual sería absurdo. El compilador lo emite como System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute en el constructor, y se emite automáticamente detrás del constructor de copia generado de un record para que las expresiones with funcionen.
Este es portador en el sentido más fuerte: descártalo y el código regenerado ni siquiera compila de la misma manera. Un decompilador que omite [SetsRequiredMembers] produce un constructor que el compilador juzga entonces que no satisface los miembros requeridos, así que cualquier llamador con inicializador de objeto se ve de repente obligado a poner propiedades que el diseño original le dejaba omitir, o, si el constructor es el único camino, el tipo se vuelve incómodo de usar exactamente del modo que el atributo existía para evitar. Glass.NET lo saca a la superficie en el constructor tal cual, porque el tipo no da la vuelta completa sin él.
Leer ambas señales, de forma independiente
La razón por la que ambas funcionalidades necesitan cuidado es que son ortogonales, y sus cuatro combinaciones son cuatro contratos distintos:
public string A { get; set; } // mutable, optional
public string B { get; init; } // set once, optional
public required string C { get; set; } // must be set, then mutable
public required string D { get; init; } // must be set, construction only
En los metadatos esa es la combinación independiente de dos señales: el modreq IsExternalInit en el setter (presente → init, ausente → set) y el RequiredMemberAttribute en el miembro (presente → required, ausente → no). Un decompilador debe leerlas por separado y combinarlas, porque cada una colapsada por su cuenta produce una respuesta errónea distinta: pierde el modreq y D se vuelve reasignable; pierde el atributo y D se vuelve opcional; pierde ambos y es una propiedad mutable simple sin ninguna de las garantías que el autor escribió.
Ese es el hilo conductor de toda esta familia de funcionalidades de C#. La palabra clave que escribiste no dejó casi rastro en el código ejecutable —ningún IL nuevo en los cuerpos, solo un modificador en una firma y un puñado de atributos en las declaraciones—. El compilador puso el contrato en los metadatos y confió en que el siguiente compilador lo leyera. Un decompilador que quiere devolverte el fuente que el autor escribió realmente tiene que ser ese siguiente lector: inspeccionar los modificadores personalizados, conservar los atributos que codifican características del lenguaje en lugar de filtrarlos como ruido, y reconstruir init, required y [SetsRequiredMembers] con precisión, para que el tipo que recuperas fuerce exactamente lo que forzaba el original.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.