Descompilar parámetros ref readonly: a un solo atributo de distancia de in
ref readonly e in compilan a metadatos de parámetro idénticos byte a byte: el mismo flag [in], el mismo modreq(InAttribute). La única diferencia es un atributo personalizado. Esto es lo que lee Glass.NET.
Los parámetros ref readonly, añadidos en C# 12, parecen que deberían ser algo importante en los metadatos. Son una nueva dirección para pasar argumentos, una nueva regla sobre lo que se permite pasar y un cuarto miembro de la familia por referencia junto a ref, out e in. Así que resulta un poco sorprendente que en el ensamblado compilado un parámetro ref readonly sea idéntico byte a byte a un parámetro in salvo por un único atributo personalizado. Ese atributo es todo el trabajo que un descompilador tiene que hacer aquí, y es también exactamente la pieza que un descompilador que solo lee firmas pasará por alto, convirtiendo en silencio cada ref readonly de nuevo en in. Esta entrada recorre los metadatos de ambos y muestra qué reconstruye Glass.NET.
Dos palabras clave, una forma binaria
Empecemos con dos métodos que son deliberadamente iguales salvo por la dirección del parámetro:
public static class Geometry
{
public static double LengthIn(in Vector3 v) =>
Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
public static double LengthReq(ref readonly Vector3 v) =>
Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
}
in existe desde C# 7.2; ref readonly es el primo más nuevo y estricto. La diferencia práctica en el código fuente tiene que ver con lo que el llamador puede pasar. Un parámetro in acepta cualquier cosa en silencio — una variable, un campo, incluso un literal — y el compilador conjura un temporal oculto para los casos que no son ya una ubicación. Un parámetro ref readonly quiere una ubicación real y advierte cuando no la recibe, que es precisamente el punto: es para APIs que toman una referencia para evitar copiar una estructura grande, donde pasar un temporal desechable casi siempre es un error que el autor quiere que se señale. Esa es una regla en tiempo de compilación, aplicada por el compilador y reportada como advertencia, y — crucialmente — no deja rastro alguno en la forma del parámetro.
Leer los metadatos del parámetro
Aquí es donde los dos métodos dejan de verse distintos. Descompila LengthIn a IL y el parámetro es una referencia administrada con un flag direccional y un modificador requerido:
.method public hidebysig static float64 LengthIn (
[in] valuetype Vector3& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) v
) cil managed
{
.param [1]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
// ...
}
Tres cosas codifican el in. El flag [in] es la dirección del parámetro (ParamAttributes.In). El modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) es un modificador personalizado requerido soldado al tipo Vector3& en la firma — modreq, no modopt, de modo que forma parte de la firma y un consumidor que no lo entienda debe negarse a enlazar. Y el bloque .param [1] lleva un System.Runtime.CompilerServices.IsReadOnlyAttribute, la marca que dice “esta referencia es de solo lectura”.
Ahora LengthReq, la versión ref readonly:
.method public hidebysig static float64 LengthReq (
[in] valuetype Vector3& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) v
) cil managed
{
.param [1]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.RequiresLocationAttribute::.ctor() = ( 01 00 00 00 )
// ...
}
Lee las dos firmas una al lado de la otra. El tipo de retorno es el mismo, el flag [in] es el mismo y el modreq(InAttribute) es el mismo modificador requerido. La única diferencia en todo el método es el nombre del atributo personalizado en el bloque .param: IsReadOnlyAttribute para in, RequiresLocationAttribute para ref readonly. Esa es toda la distinción, y es un atributo personalizado corriente — ni un modificador, ni un flag, ni nada que participe en la coincidencia de firmas.
Por qué la forma se comparte a propósito
Esto no es un accidente de implementación; es la restricción que define la característica. El equipo del lenguaje quería dos cosas a la vez. Primero, cambiar un parámetro in ya existente en una biblioteca publicada por ref readonly no debe romper a ningún llamador compilado contra la firma antigua — está pensado para ser un endurecimiento seguro, compatible a nivel de fuente y binario. Segundo, un compilador que sí entiende ref readonly debe aplicar la regla más estricta de “pasar una ubicación”.
Un modificador requerido no puede cumplir el primer objetivo. Todo el significado de modreq es “niégate a enlazar si no entiendes esto”, así que un modreq(RequiresLocation) dedicado haría que todo llamador anterior a C# 12 fallara al compilar — justo lo contrario de compatible. Por eso ref readonly reutiliza la codificación binaria existente de in, [in] más modreq(InAttribute), que los compiladores antiguos ya tratan como un parámetro in corriente, y superpone el nuevo significado mediante un atributo personalizado — RequiresLocationAttribute — que los compiladores antiguos simplemente ignoran igual que ignoran cualquier atributo que no reconocen. El modreq es para la compatibilidad; el atributo es para la nueva semántica. Esa división es por lo que la palabra clave correcta solo puede recuperarse desde el atributo.
De dónde sale RequiresLocationAttribute
Si vas a buscar System.Runtime.CompilerServices.RequiresLocationAttribute y no está en el framework al que apuntaste, es lo esperado — el compilador lo genera. Cuando el framework de destino no define el tipo, Roslyn incrusta una copia privada directamente en el ensamblado que se está construyendo, marcada con Microsoft.CodeAnalysis.EmbeddedAttribute y CompilerGenerated, exactamente como hace con IsReadOnlyAttribute, IsExternalInit y los atributos de contexto de nulabilidad cuando faltan. Glass.NET reconoce los tipos de atributo incrustados por lo que son — fontanería del compilador — y usa RequiresLocationAttribute para reconstruir ref readonly sin mostrar el tipo incrustado en sí como código que tú hayas escrito.
El sitio de llamada también es idéntico
Es tentador esperar que la llamada desambigüe el parámetro, pero no lo hace. Tanto in como ref readonly se pasan por referencia de solo lectura, así que el compilador toma una dirección y llama:
// Geometry.LengthReq(in v); — v es una variable local
ldloca.s v
call float64 Geometry::LengthReq(valuetype Vector3& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute))
Ese es el mismo ldloca + call que produce una llamada in, y el tipo del parámetro en el token de la llamada es el mismo Vector3& modreq(InAttribute). Si pasas un rvalue al método ref readonly — que el compilador acepta con una advertencia como CS9193 (“el argumento debería ser una variable”) — simplemente vuelca el valor en un temporal de solo lectura primero y toma su dirección; sigue siendo una dirección, sigue siendo la misma forma de llamada. Nada en el sitio de llamada registra si el método llamado declaró in o ref readonly. Por eso Glass.NET representa la palabra clave de cada argumento según el parámetro al que se enlaza en la definición del método llamado, no a partir de nada estampado en la llamada.
Lo que un descompilador que solo mira la firma hace mal
Un descompilador que reconstruye los parámetros solo a partir de la firma — el flag [in] y el modreq(InAttribute) — no tiene forma de distinguir ambos, porque son idénticos. La salida honesta de ese enfoque es representar todos esos parámetros como in, lo cual es incorrecto para cada ref readonly del ensamblado. Es un tipo silencioso de error: el código aún compila y aún se ejecuta, así que nada lo señala, pero la API recuperada ahora reclama un contrato más laxo del que el autor escribió, y cualquiera que la lea para entender la intención de la biblioteca — ¿por qué este método toma una referencia y le molesta un temporal? — queda engañado.
Glass.NET lee los atributos personalizados de .param y resuelve la dirección desde ahí: RequiresLocationAttribute produce ref readonly, IsReadOnlyAttribute produce in, y un modreq(InAttribute) desnudo sin ninguno de los dos atributos — que es lo que pueden producir IL escrito a mano u otro lenguaje — recurre a in, el significado nativo de la forma. El resultado es el código fuente del que partiste:
public static double LengthIn(in Vector3 v) => /* ... */;
public static double LengthReq(ref readonly Vector3 v) => /* ... */;
Puedes confirmarlo de ambas formas en Glass: la vista de C# descompilado muestra ref readonly en el parámetro, y la vista de IL a su lado muestra el RequiresLocationAttribute en el bloque .param [1] que lo produjo, con el modreq(InAttribute) compartido justo ahí en la firma para que veas que la palabra clave realmente vino del atributo y de ningún otro sitio.
La conclusión
ref readonly es una de esas características en las que la historia interesante de los metadatos es lo poco que hay. El parámetro se codifica como un in corriente — flag [in], modreq(System.Runtime.InteropServices.InAttribute) — precisamente para que los compiladores más antiguos sigan enlazándose a él, y toda la distinción cabalga sobre un único atributo personalizado, RequiresLocationAttribute, que a menudo el compilador tiene que incrustar en el propio ensamblado. Lee el atributo y recuperas ref readonly; lee solo la firma y obtienes in para todo, de apariencia correcta y sutilmente erróneo. Glass.NET lee el atributo, recurre a in solo cuando de verdad no hay nada más en lo que basarse, y te muestra el IL al lado para que veas de qué mitad de los metadatos vino la palabra clave.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.