Skip to content
← Tous les articles
· Delta1 Labs Glass.NETDécompilation.NETAnalyse approfondie

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..

membre d'instance (depuis les génériques)constrained. !!T · callvirt IFoo::Bar()répartit sur le receveur ; évite le boxing d'un valeurmembre statique abstrait (C# 11)constrained. !!T · call T::op_Addition(..)sans receveur ; arg de type vers répartition statiqueun décompilateur naïf supposeconstrained. ⇒ callvirt seulement→ lit mal l'appel statique : receveurfantôme, ou cast inventé

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_One portent l’indicateur specialname et la forme d’accesseur de propriété, donc ils se relisent comme la propriété Zero / One : T.Zero.
  • op_Addition, op_Subtraction, op_Equality et le reste portent specialname static et 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.