Skip to content
← Todas las publicaciones
· Delta1 Labs DecompilationGlass

Descompilar restricciones genéricas y varianza: reconstruir cláusulas where y out/in desde los metadatos

Las restricciones de parámetros de tipo genérico y la varianza son, igual que el diseño de estructuras, rasgos que un descompilador no puede recuperar a partir de la forma del código: viven por completo en los metadatos. Cada parámetro genérico lleva una dirección de varianza y marcas de restricción especial en la tabla GenericParam, y la tabla GenericParamConstraint enumera el tipo base y las interfaces a los que está ligado un parámetro. La covarianza (out) y la contravarianza (in) son bits de marca; las restricciones class / struct / new() también son bits; pero notnull y unmanaged se codifican de forma indirecta, mediante un modreq y un atributo en lugar de una marca cruda. Pierde la varianza y una interfaz deja de ser asignable como lo era; vuelve a emitir las restricciones en el orden equivocado y la salida no compila en absoluto. Este artículo muestra dónde viven los datos, cómo Glass.NET lee las tablas GenericParam y GenericParamConstraint, cómo ordena las restricciones en el orden exacto que exige C#, y un ejemplo trabajado con una interfaz covariante y un método con varias restricciones.

La mayoría de los rasgos de C# que un descompilador recupera son azúcar del compilador: el compilador bajó un foreach a un try/finally alrededor de un enumerador, o un iterador a una máquina de estados, y el descompilador reconoce la forma del código emitido y lo repliega. Las restricciones genéricas y la varianza no son así. No hay código que reconocer, porque una restricción no es ejecutable. where T : class, IComparable<T>, new() y el out de IProducer<out T> se compilan a metadatos —filas en un par de tablas auxiliares y un puñado de bits de marca— y nada en ningún cuerpo de método los menciona. Un descompilador que solo reconstruye IL en sentencias emitirá un tipo genérico al que le falta la varianza, le faltan las restricciones, o las lleva en un orden que no compila.

Esto importa porque las restricciones y la varianza forman parte del contrato público de un tipo. Las restricciones deciden qué puede sustituir un llamador por T; la varianza decide qué asignaciones entre tipos construidos son legales. Equivócate y el tipo regenerado o bien rechaza código que antes funcionaba o, peor, afloja en silencio una garantía de la que dependían los llamadores. Este artículo recorre exactamente dónde viven los datos en los metadatos, cómo los lee Glass y cómo ordena las restricciones en el único orden que C# aceptará.

Varianza y restricciones en el código

Estas son las tres formas que este artículo reconstruye. Primero una interfaz covariante: T aparece solo en posición de salida, así que puede marcarse out:

public interface IProducer<out T>
{
    T Produce();
}

La covarianza es lo que permite entregar un IProducer<string> a código que espera un IProducer<object>: cada string que produce el productor es un object, así que la asignación es segura y el compilador la permite. La imagen especular es la contravarianza: T aparece solo en posición de entrada, así que puede marcarse in:

public interface ISink<in T>
{
    void Accept(T value);
}

Un ISink<object> puede hacer las veces de un ISink<string>, porque cualquier cosa que acepte cualquier object sin duda acepta un string. Por último, un método con un juego completo de restricciones:

public static T CreateAndRank<T>(IEnumerable<T> items)
    where T : class, IComparable<T>, new()
{
    var seed = new T();
    foreach (var item in items)
        if (item.CompareTo(seed) < 0)
            seed = item;
    return seed;
}

T debe ser un tipo referencia (class), debe ser comparable consigo mismo (IComparable<T>) y debe tener un constructor público sin parámetros (new()). Los tres hechos quedan registrados en los metadatos, en una forma concreta y sin orden que el descompilador tiene que volver a convertir en una cláusula where legal y ordenada.

Dónde viven realmente los datos

Dos tablas de metadatos y un mapa de bits lo llevan todo.

La tabla GenericParam tiene una fila por parámetro de tipo, tanto para tipos como para métodos. Cada fila almacena la posición del parámetro (base cero), su nombre, su propietario y una columna Flags con dos grupos de bits independientes:

  • El VarianceMask (dos bits bajos): 0x0 no variante, 0x1 covariante (out), 0x2 contravariante (in). Solo es distinto de cero en parámetros de interfaces y delegados.
  • El SpecialConstraintMask: 0x04 restricción de tipo referencia (class), 0x08 restricción de tipo valor / no anulable (struct), 0x10 restricción de constructor por defecto (new()).

La tabla GenericParamConstraint tiene una fila por restricción de tipo, cada una emparejando una fila GenericParam con un TypeDefOrRef —la clase base o la interfaz a la que está ligado el parámetro—. where T : SomeBase, IComparable<T> produce dos filas para el mismo parámetro: una apuntando a SomeBase, otra a IComparable<T>.

Representada como la muestra ILDasm, la interfaz covariante pone +T justo en la cabecera de la clase:

.class interface public abstract auto ansi IProducer`1<+T>
{
    .method public hidebysig newslot abstract virtual
        instance !T Produce() cil managed
    {
    }
}

El +T es el bit covariante del VarianceMask; un parámetro contravariante se representa como -T, y uno no variante como un simple T. El 1 es la aridad. Para el método con restricciones, ILDasm imprime la restricción de tipo base entre paréntesis antes del nombre del parámetro y las restricciones especiales como palabras clave sobre el parámetro:

.method public hidebysig static !!T CreateAndRank<class .ctor
    ([mscorlib]System.IComparable`1<!!T>) T>
    (class [mscorlib]System.Collections.Generic.IEnumerable`1<!!T> items)
    cil managed
{
}

Lee esa cabecera con cuidado: class y .ctor son los bits del SpecialConstraintMask (class + new()), el ([mscorlib]System.IComparable1<!!T>)entre paréntesis es la única filaGenericParamConstraint, y T` es el nombre del parámetro. Nada de esto aparece en ninguna instrucción: son metadatos puramente declarativos adjuntos a la firma del método.

El matiz de notnull y unmanaged

Las tres restricciones especiales clásicas poseen cada una un bit del SpecialConstraintMask. Las dos modernas no, porque el mapa de bits se congeló antes de que existieran, y se codifican de forma indirecta:

  • notnull se transporta mediante un atributo de System.Runtime.CompilerServices sobre el GenericParam, sin ninguna marca de restricción especial activada. Un lector que solo consulte el SpecialConstraintMask no ve nada y emite un parámetro sin restricciones, perdiendo la restricción por completo.
  • unmanaged se codifica como la marca struct (0x08) más una fila GenericParamConstraint cuyo tipo lleva un modificador requerido —un modreq de System.Runtime.CompilerServices.IsUnmanagedAttribute— sobre una restricción System.ValueType. Un lector que solo vea el bit struct emite where T : struct, que es una restricción estrictamente más débil que unmanaged y deja pasar en silencio tipos valor administrados.

En IL el caso unmanaged se ve como un parámetro marcado con struct y con una restricción ValueType con modreq:

.method ... static void Blit<valuetype .ctor
    (class [System.Runtime]System.ValueType modreq(
        [System.Runtime]System.Runtime.CompilerServices.IsUnmanagedAttribute)) T>
    (...) cil managed

La presencia del modreq(...IsUnmanagedAttribute) es toda la diferencia entre unmanaged y struct. Glass lo comprueba de forma explícita, igual que el atributo notnull, de modo que ninguna de las dos restricciones modernas se pierde ni se amplía.

Lo que produce un descompilador ingenuo

Un descompilador construido en torno a los cuerpos de método no tiene aquí nada a lo que aferrarse, porque nada de esto está en un cuerpo de método. Predominan dos modos de fallo, y ambos son silenciosos.

Varianza perdida. La interfaz vuelve invariante:

// MAL — el out ha desaparecido
public interface IProducer<T>
{
    T Produce();
}

Esto compila. Su único miembro es idéntico. Pero ya no es covariante, así que toda asignación de IProducer<string> a IProducer<object> que los llamadores escribieron contra el original ahora falla al compilar contra la interfaz regenerada. Nada en la propia IProducer parece mal; la rotura aparece solo en los puntos de asignación, con frecuencia en otros ensamblados.

Orden de restricciones equivocado. Un descompilador que recorre los metadatos y añade palabras clave según las encuentra emite algo como:

// MAL — no compila
public static T CreateAndRank<T>(IEnumerable<T> items)
    where T : IComparable<T>, class, new(), struct
{ ... }

C# lo rechaza de plano. La restricción class debe ir primero, new() debe ir al final, class y struct son mutuamente excluyentes, y las restricciones de interfaz van en medio. Los metadatos no conservan nada de este orden —las filas GenericParamConstraint están en un orden arbitrario y los bits de marca no tienen orden alguno— así que el descompilador tiene que imponer el orden correcto en lugar de repetir lo que leyó.

La figura siguiente muestra las dos fuentes de metadatos alimentando el ordenador de restricciones de Glass, que clasifica cada restricción por categoría y emite los grupos en el orden fijo de C#:

GenericParam.Flags (parámetro T) SpecialConstraint: class | .ctor VarianceMask: 0x0 (no variante) Filas de GenericParamConstraint T -> IComparable`1<!T> Glass ordenador de restricciones Orden fijo de C# que emite el ordenador class IComparable<T> new() class/struct primero, luego tipo base, luego interfaces, luego new() al final. where T : class, IComparable<T>, new() El orden lo impone la regla del lenguaje, no se lee de la tabla: las filas no llevan orden.

Cómo lo reconstruye Glass

Glass trata los parámetros genéricos como un asunto de metadatos de primera clase, separado de la descompilación de métodos. Para cada parámetro —de tipo o de método— ejecuta una canalización explícita:

  1. Lee el VarianceMask de GenericParam.Flags. Covariante se vuelve out, contravariante se vuelve in, y la palabra clave se coloca en la declaración (IProducer<out T>), no en la cláusula where. La varianza solo se lee para parámetros de interfaces y delegados, donde es legal.
  2. Lee el SpecialConstraintMask en busca de los bits class, struct y new(), pero antes de confiar en un bit struct desnudo, comprueba si la restricción ValueType lleva el modreq IsUnmanagedAttribute, en cuyo caso la restricción es unmanaged, no struct. Comprueba también los atributos del GenericParam por el marcador notnull.
  3. Lee cada fila GenericParamConstraint del parámetro y resuelve cada TypeDefOrRef a una clase base o interfaz. Un tipo clase va al grupo de tipo base; las interfaces van al grupo de interfaces.
  4. Clasifica en el orden fijo de C# y emite: la restricción class / struct / unmanaged / notnull primero, luego el tipo base, luego las interfaces, luego new() al final. Los grupos vacíos se omiten, y un parámetro sin restricciones no obtiene cláusula where.

El resultado es el código del que partiste, equivalente contrato a contrato:

public static T CreateAndRank<T>(IEnumerable<T> items)
    where T : class, IComparable<T>, new()
{
    var seed = new T();
    foreach (var item in items)
        if (item.CompareTo(seed) < 0)
            seed = item;
    return seed;
}

Como las palabras clave class y new() vinieron del SpecialConstraintMask e IComparable<T> vino de la fila GenericParamConstraint, y como el ordenador las colocó en el orden que C# exige, el método recompila con exactamente las restricciones que declaraba el original —ni más fuertes ni más débiles, y en un orden que el compilador acepta—.

Una interfaz covariante, de ida y vuelta

La reconstrucción de la interfaz es más corta pero muestra el recorrido de la varianza de principio a fin. Glass lee el bit covariante +T de la única fila GenericParam y vuelve a emitir el out:

public interface IProducer<out T>
{
    T Produce();
}

El out vuelve a la declaración, así que IProducer<string> es de nuevo asignable a IProducer<object>. Si Glass hubiera leído solo las firmas de los miembros y hubiera saltado el VarianceMask, la interfaz habría vuelto invariante y toda asignación variante aguas abajo se habría roto: el tipo de pérdida que es invisible en el propio tipo y solo aflora en los puntos de llamada.

Ejecutarlo

Apunta Glass al ensamblado y pide el único tipo. Observa la aridad con acento grave en el nombre del tipo:

glass decompile MyApp.dll --type MyApp.Abstractions.IProducer`1

La salida es la interfaz anotada con out de arriba. Para descompilar el tipo que declara el método con restricciones y confirmar que las restricciones hacen el viaje de ida y vuelta:

glass decompile MyApp.dll --type MyApp.Ranking.Ranker --members

Para barrer un espacio de nombres completo y verificar que la varianza y las restricciones de cada parámetro genérico sobreviven a la reconstrucción:

glass decompile MyApp.dll --namespace MyApp.Abstractions --constraint-check

--constraint-check vuelve a leer las tablas GenericParam y GenericParamConstraint tras la reconstrucción y verifica que las palabras clave de varianza, las restricciones especiales y los enlaces de tipo de la cláusula where emitidos coinciden exactamente con los metadatos, de modo que obtienes una garantía, no una esperanza, de que las firmas regeneradas llevan el mismo contrato que el original. Glass es un descompilador libre y abierto; esto es salida estándar, nada restringido.

Por qué vale la pena acertar con esto

La varianza y las restricciones son la parte de un tipo genérico que ven sus usuarios. Un out perdido no cambia los miembros de IProducer, pero revoca en silencio una promesa de asignabilidad sobre la que se construyó otro código. Una cláusula where mal ordenada no cambia el significado de T, pero impide que el archivo compile. Y un struct emitido donde los metadatos decían unmanaged afloja una garantía sin una sola advertencia, porque la restricción más débil sigue aceptando todo lo que aceptaba la más estricta y más.

Así que la regla que sigue Glass es la misma que gobierna el diseño de estructuras: esto es metadatos, los metadatos son la verdad fundamental, y la reconstrucción los lee en lugar de adivinar. Lee el VarianceMask, lee el SpecialConstraintMask (y el modreq y el atributo que se esconden tras un bit struct desnudo), lee cada fila GenericParamConstraint y vuelve a emitirlo todo en el único orden que C# acepta. Cuando inspeccionas o depuras código genérico que tienes derecho a leer, esa fidelidad es la diferencia entre una firma en la que puedes confiar y una que cambia el contrato en silencio.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.