Décompiler les membres d'interface statiques abstraits : comment se compile la mathématique générique
C# 11 a permis aux interfaces de déclarer des membres statiques abstraits, et la BCL s'en est servi pour bâtir la mathématique générique — écrivez Sum<T>(span) une fois et elle tourne sur int, double ou votre propre type. Mais la fonctionnalité s'appuie sur un recoin de la spécification ECMA que la plupart des décompilateurs n'avaient jamais eu à traiter : un appel constrained vers une méthode d'interface *statique*, résolu vers l'argument de type à l'exécution. Mal géré, le décompilateur émet un appel vers une méthode qui semble n'avoir aucun receveur, ou invente un cast qui n'existe pas. Voici exactement ce que le compilateur émet pour T.Zero et a + b sur une contrainte générique, pourquoi le préfixe constrained. sur un appel statique non virtuel est toute l'astuce, et comment Glass.NET le relit en les opérateurs et membres statiques que vous avez réellement écrits.
C# 11 a ajouté une fonctionnalité au nom modeste aux conséquences majeures : les interfaces peuvent déclarer des membres static abstract. C’est ce qui a rendu possible la mathématique générique — vous pouvez désormais écrire Sum<T>(ReadOnlySpan<T>) une fois, contraindre T à INumber<T>, et la faire tourner sur int, double, decimal ou un type que vous avez défini, l’opérateur + se résolvant vers l’implémentation propre à chaque type. Cela se lit comme des génériques ordinaires. En dessous, cela dépend d’un mécanisme de répartition qui n’existait pas auparavant : appeler une méthode d’interface statique via un paramètre de type, résolu vers l’argument de type à l’exécution.
Ce mécanisme est là où les décompilateurs gagnent leur pain. L’IL correspondant réutilise un vieux préfixe d’une nouvelle façon, et un outil qui a appris la vieille règle — « constrained. précède callvirt » — lit mal la nouvelle forme. Cet article parcourt l’IL exact que le compilateur émet pour T.Zero et a + b sur une contrainte générique, explique pourquoi le préfixe constrained. se place sur un call simple, et montre comment Glass.NET le reconvertit en les opérateurs et membres statiques que vous avez écrits.
Le code source, et ce qui le rend épineux
Voici la méthode canonique de mathématique générique. Aucune mention d’un type numérique concret ; tout passe par la contrainte :
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 et total += v ressemblent à un accès de membre et à un opérateur. Mais T est un paramètre de type — à la compilation, il n’y a aucun type concret auquel lier l’appel. L’exécution doit résoudre Zero et op_Addition vers ce que T se révèle être. Cette résolution est le travail du préfixe constrained..
Les deux lignes sont le nœud. Le préfixe est identique, mais ce qu’il décore — et ce qu’il signifie — ne l’est pas.
L’IL que le compilateur émet
Compilez en Release et le corps de Sum<T> se réduit à ceci (annoté). Notez qu’il n’y a pas de callvirt ni pour l’opérateur ni pour la propriété ; les deux sont des call simples avec un préfixe constrained. portant !!T, le propre paramètre de type de la méthode :
.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
}
Deux choses s’en déduisent. D’abord, T.Zero est un appel à get_Zero — l’accesseur d’une propriété statique déclarée sur INumberBase<T> — préfixé par constrained. !!T. Ensuite, total += v est un appel à op_Addition, la méthode-opérateur au nom spécial sur IAdditionOperators<T,T,T>, à nouveau préfixé par constrained. !!T. Le préfixe est ce qui dit à l’exécution : ne cherche pas une méthode statique sur l’interface elle-même ; résous-la contre le type concret lié à !!T à cette instanciation. Retirez mentalement le préfixe et l’instruction n’a aucun sens — un call vers une méthode d’interface abstraite n’a aucun corps à exécuter. Le préfixe est tout le mécanisme.
Un décompilateur qui traîne la vieille hypothèse — que constrained. ne décore qu’un callvirt, comme c’est le cas depuis C# 2 pour les receveurs d’instance de type valeur — bute sur ce call et n’a aucune règle pour lui. Les modes d’échec typiques sont un receveur fantôme (il tente de rendre le premier opérande de la pile comme la chose appelée) ou un cast inventé vers l’interface (il « explique » le préfixe comme une conversion). Dans les deux cas, la sortie ne correspond plus au source et ne recompile plus.
Le relire
Glass gère la forme de façon générique. Quand il voit constrained. !!X suivi d’un call vers une méthode static abstract (ou static virtual) sur une interface, il enregistre que le type de receveur effectif est le paramètre de type !!X et que la répartition est statique. À partir de là, il résout l’orthographe depuis les métadonnées de la méthode :
get_Zero/get_Oneportent l’indicateurspecialnameet la forme d’accesseur de propriété, donc ils se relisent comme la propriétéZero/One:T.Zero.op_Addition,op_Subtraction,op_Equalityet le reste portentspecialname staticet un nom d’opérateur réservé, donc ils se relisent comme l’opérateur :total + v.
En appliquant les deux, le source récupéré est la méthode de départ :
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 contrainte elle-même vient directement des métadonnées du paramètre générique : (class System.Numerics.INumber1<!!T>) Tdans la signature de la.methoddevientwhere T : INumber. Le foreachse reconstruit à partir du patron d'énumération du span (son propre problème de décompilation, traité à part), mais le cœur de la mathématique générique —T.Zeroettotal += v` — repose entièrement sur la lecture correcte de l’appel constrained statique.
Ce n’est pas que la BCL
La mathématique générique est le client célèbre, mais les membres static abstract sont une fonctionnalité générale, et la même forme d’IL apparaît partout où vous en utilisez un. Une contrainte de fabrique, par exemple :
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 compile au patron identique — constrained. !!TSelf sur un call vers le Create statique abstrait. Parce que Glass se fonde sur la forme de l’instruction et les indicateurs du membre, pas sur une liste figée d’interfaces numériques, vos propres membres static abstract se décompilent exactement comme ceux du framework : un accès de membre statique sur le paramètre de type, écrit tel que vous l’avez écrit. Un outil qui spécialiserait INumber<T> pour passer sa propre suite de tests rendrait celui-ci de travers ; un qui gère le mécanisme ECMA les gère tous.
À retenir
Les membres d’interface statiques abstraits ont rendu possible la mathématique générique, et ils l’ont fait en réutilisant le préfixe constrained. sur un call simple pour porter un argument de type dans la répartition statique — une forme qui n’existait pas avant C# 11. La règle naïve « constrained. précède callvirt » la lit mal, produisant des receveurs fantômes ou des casts inventés, donc un décompilateur correct doit reconnaître l’appel constrained statique comme son propre patron : le type du receveur est le paramètre de type, la répartition est statique, et l’orthographe vient du nom spécial du membre — get_Zero vers T.Zero, op_Addition vers +. Faites cela de façon générique, à partir de l’instruction et des indicateurs de métadonnées plutôt que d’une liste d’interfaces connues, et aussi bien la pile numérique de la BCL que vos propres membres static abstract de style fabrique ou analyseur se relisent dans le source que vous avez écrit. Pour une énigme de répartition apparentée — récupérer des appels qui ne portent aucun jeton de cible — voyez décompiler les pointeurs de fonction et calli ; pour les tables de métadonnées où vivent ces indicateurs, les tables de métadonnées de .NET expliquées.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.