Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompilación.NETAnálisis a fondo

Descompilar miembros de interfaz estáticos abstractos: cómo se compila la matemática genérica

C# 11 dejó que las interfaces declararan miembros estáticos abstractos, y la BCL lo usó para construir la matemática genérica: escribe Sum<T>(span) una vez y corre sobre int, double o tu propio tipo. Pero la característica se apoya en un rincón de la especificación ECMA que la mayoría de descompiladores nunca tuvo que manejar: una llamada constrained a un método de interfaz *estático*, resuelta al argumento de tipo en tiempo de ejecución. Si se hace mal, el descompilador emite una llamada a un método que parece no tener receptor, o inventa un cast que no está. Esto es exactamente lo que el compilador emite para T.Zero y a + b sobre una restricción genérica, por qué el prefijo constrained. sobre una llamada estática no virtual es todo el truco, y cómo Glass.NET lo lee de vuelta en los operadores y miembros estáticos que de verdad escribiste.

C# 11 añadió una característica de nombre modesto con consecuencias grandes: las interfaces pueden declarar miembros static abstract. Eso es lo que hizo posible la matemática genérica: ahora puedes escribir Sum<T>(ReadOnlySpan<T>) una vez, restringir T a INumber<T>, y hacer que corra sobre int, double, decimal, o un tipo que definiste tú, con el operador + resolviéndose a la implementación propia de cada tipo. Se lee como genéricos ordinarios. Por debajo, depende de un mecanismo de despacho que antes no existía: llamar a un método de interfaz estático a través de un parámetro de tipo, resuelto al argumento de tipo en tiempo de ejecución.

Ese mecanismo es donde los descompiladores se ganan el sueldo. El IL para ello reutiliza un prefijo viejo de una forma nueva, y una herramienta que aprendió la regla vieja —“constrained. precede a callvirt”— lee mal la forma nueva. Este artículo recorre el IL exacto que el compilador emite para T.Zero y a + b sobre una restricción genérica, explica por qué el prefijo constrained. se sitúa sobre un call plano, y muestra cómo Glass.NET lo convierte de vuelta en los operadores y miembros estáticos que escribiste.

El código fuente, y qué lo hace espinoso

Aquí está el método canónico de matemática genérica. No hay mención de ningún tipo numérico concreto; todo fluye a través de la restricción:

using System.Numerics;

public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
    T total = T.Zero;            // static abstract property on the interface
    foreach (T v in values)
        total += v;              // static abstract operator op_Addition
    return total;
}

T.Zero y total += v parecen un acceso a miembro y un operador. Pero T es un parámetro de tipo: en tiempo de compilación no hay un tipo concreto al que ligar la llamada. El runtime tiene que resolver Zero y op_Addition a lo que T resulte ser. Esa resolución es el trabajo del prefijo constrained..

miembro de instancia (desde los genéricos)constrained. !!T · callvirt IFoo::Bar()despacha sobre el receptor; evita boxing de un valormiembro estático abstracto (C# 11)constrained. !!T · call T::op_Addition(..)sin receptor; arg de tipo al despacho estáticoun descompilador ingenuo asumeconstrained. ⇒ solo callvirt→ lee mal la llamada estática: receptorfantasma, o un cast inventado

Las dos filas son el meollo. El prefijo es idéntico, pero lo que decora —y lo que significa— no lo es.

El IL que emite el compilador

Compila en Release y el cuerpo de Sum<T> se reduce a esto (anotado). Fíjate en que no hay callvirt ni para el operador ni para la propiedad; ambos son calls planos con un prefijo constrained. que lleva !!T, el propio parámetro de tipo del método:

.method public hidebysig static !!T Sum<(class System.Numerics.INumber`1<!!T>) T>(
        valuetype System.ReadOnlySpan`1<!!T> values) cil managed
{
  // T total = T.Zero;
  constrained. !!T
  call       !!T System.Numerics.INumberBase`1<!!T>::get_Zero()
  stloc.0                                   // total

  // foreach (T v in values) total += v;
  ...                                        // span enumeration elided
  ldloc.0                                    // total
  ldloc.2                                    // v
  constrained. !!T
  call       !!T System.Numerics.IAdditionOperators`3<!!T,!!T,!!T>::op_Addition(!0, !1)
  stloc.0                                    // total = total + v
  ...
  ldloc.0
  ret
}

Dos cosas se leen de esto. Primera, T.Zero es una llamada a get_Zero —el getter de una propiedad estática declarada en INumberBase<T>— prefijada con constrained. !!T. Segunda, total += v es una llamada a op_Addition, el método operador de nombre especial en IAdditionOperators<T,T,T>, de nuevo prefijada con constrained. !!T. El prefijo es lo que le dice al runtime: no busques un método estático en la interfaz misma; resuélvelo contra el tipo concreto ligado a !!T en esta instanciación. Quita el prefijo mentalmente y la instrucción es un sinsentido: un call a un método de interfaz abstracto no tiene cuerpo que ejecutar. El prefijo es el mecanismo entero.

Un descompilador que arrastra la asunción vieja —que constrained. solo decora un callvirt, como ha hecho desde C# 2 para receptores de instancia de tipo valor— choca con este call y no tiene regla para él. Los modos de fallo típicos son un receptor fantasma (intenta renderizar el primer operando de la pila como la cosa a la que se llama) o un cast inventado a la interfaz (lo “explica” como una conversión). En cualquier caso la salida ya no coincide con el fuente y ya no recompila.

Leerlo de vuelta

Glass maneja la forma de manera genérica. Cuando ve constrained. !!X seguido de un call a un método que es static abstract (o static virtual) en una interfaz, registra que el tipo de receptor efectivo es el parámetro de tipo !!X y que el despacho es estático. A partir de ahí resuelve la ortografía desde los metadatos del método:

  • get_Zero / get_One llevan la marca specialname y la forma de accesor de propiedad, así que se leen de vuelta como la propiedad Zero / One: T.Zero.
  • op_Addition, op_Subtraction, op_Equality y el resto llevan specialname static y un nombre de operador reservado, así que se leen de vuelta como el operador: total + v.

Aplicando ambos, el fuente recuperado es el método con el que empezaste:

public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (T v in values)
        total += v;
    return total;
}

La restricción en sí viene directamente de los metadatos del parámetro genérico: (class System.Numerics.INumber1<!!T>) Ten la firma del.methodse convierte enwhere T : INumber. El foreach se reconstruye a partir del patrón de enumeración del span (su propio problema de descompilación, tratado aparte), pero el núcleo de la matemática genérica —T.Zeroytotal += v`— depende por completo de leer correctamente la llamada constrained estática.

No es solo la BCL

La matemática genérica es el cliente famoso, pero los miembros static abstract son una característica general, y la misma forma de IL aparece dondequiera que uses uno. Una restricción de fábrica, por ejemplo:

public interface IFactory<TSelf> where TSelf : IFactory<TSelf>
{
    static abstract TSelf Create();
}

public static TSelf MakeOne<TSelf>() where TSelf : IFactory<TSelf>
    => TSelf.Create();          // constrained. !!TSelf · call !!TSelf IFactory::Create()

TSelf.Create() se compila al patrón idéntico: constrained. !!TSelf sobre un call al Create estático abstracto. Como Glass se basa en la forma de la instrucción y las marcas del miembro, no en una lista fija de interfaces numéricas, tus propios miembros static abstract se descompilan exactamente igual que los del framework: un acceso a miembro estático sobre el parámetro de tipo, escrito tal como lo escribiste. Una herramienta que especializara INumber<T> para pasar su propia batería de pruebas renderizaría este mal; una que maneja el mecanismo ECMA los maneja todos.

Qué llevarse

Los miembros de interfaz estáticos abstractos hicieron posible la matemática genérica, y lo lograron reutilizando el prefijo constrained. sobre un call plano para llevar un argumento de tipo al despacho estático: una forma que no existía antes de C# 11. La regla ingenua “constrained. precede a callvirt” la lee mal, produciendo receptores fantasma o casts inventados, así que un descompilador correcto tiene que reconocer la llamada constrained estática como su propio patrón: el tipo del receptor es el parámetro de tipo, el despacho es estático, y la ortografía viene del nombre especial del miembro —get_Zero a T.Zero, op_Addition a +—. Haz eso de forma genérica, a partir de la instrucción y las marcas de metadatos en lugar de una lista de interfaces conocidas, y tanto la pila numérica de la BCL como tus propios miembros static abstract de estilo fábrica o parseador se leen de vuelta en el fuente que escribiste. Para un acertijo de despacho relacionado —recuperar llamadas que no llevan token de objetivo en absoluto— consulta descompilar punteros a función y calli; para las tablas de metadatos donde viven estas marcas, las tablas de metadatos de .NET explicadas.

Prueba Nebula.NET

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