Décompiler les contraintes génériques et la variance : reconstruire les clauses where et out/in depuis les métadonnées
Les contraintes de paramètres de type générique et la variance sont, tout comme la disposition des structures, des traits qu'un décompilateur ne peut pas récupérer à partir de la forme du code : ils vivent entièrement dans les métadonnées. Chaque paramètre générique porte une direction de variance et des indicateurs de contrainte spéciale dans la table GenericParam, et la table GenericParamConstraint énumère le type de base et les interfaces auxquels un paramètre est lié. La covariance (out) et la contravariance (in) sont des bits d'indicateur ; les contraintes class / struct / new() sont aussi des bits ; mais notnull et unmanaged sont encodés indirectement, par un modreq et un attribut plutôt que par un indicateur brut. Perdez la variance et une interface cesse d'être assignable comme elle l'était ; réémettez les contraintes dans le mauvais ordre et la sortie ne compile pas du tout. Cet article montre où vivent les données, comment Glass.NET lit les tables GenericParam et GenericParamConstraint, comment il trie les contraintes dans l'ordre exact qu'exige C#, et un exemple travaillé avec une interface covariante et une méthode à plusieurs contraintes.
La plupart des traits de C# qu”un décompilateur récupère sont du sucre du compilateur : le compilateur a abaissé un foreach en un try/finally autour d”un énumérateur, ou un itérateur en une machine à états, et le décompilateur reconnaît la forme du code émis et la replie. Les contraintes génériques et la variance ne sont pas de cette nature. Il n”y a aucun code à reconnaître, car une contrainte n”est pas exécutable. where T : class, IComparable<T>, new() et le out de IProducer<out T> se compilent en métadonnées — des lignes dans deux tables annexes et une poignée de bits d”indicateur — et rien dans aucun corps de méthode ne les mentionne. Un décompilateur qui ne reconstruit que l”IL en instructions émettra un type générique auquel il manque la variance, auquel il manque les contraintes, ou qui les porte dans un ordre qui ne compile pas.
Cela importe parce que les contraintes et la variance font partie du contrat public d”un type. Les contraintes décident ce qu”un appelant a le droit de substituer à T ; la variance décide quelles affectations entre types construits sont légales. Trompez-vous et le type régénéré, soit rejette du code qui fonctionnait avant, soit, pire, relâche en silence une garantie sur laquelle les appelants comptaient. Cet article parcourt exactement où vivent les données dans les métadonnées, comment Glass les relit, et comment il trie les contraintes dans le seul ordre que C# acceptera.
Variance et contraintes dans le code source
Voici les trois formes que cet article reconstruit. D”abord une interface covariante : T n”apparaît qu”en position de sortie, elle peut donc être marquée out :
public interface IProducer<out T>
{
T Produce();
}
La covariance est ce qui permet de confier un IProducer<string> à du code qui attend un IProducer<object> : chaque string que le producteur fournit est un object, l”affectation est donc sûre et le compilateur l”autorise. L”image miroir est la contravariance : T n”apparaît qu”en position d”entrée, elle peut donc être marquée in :
public interface ISink<in T>
{
void Accept(T value);
}
Un ISink<object> peut tenir lieu d”un ISink<string>, car tout ce qui accepte n”importe quel object accepte assurément un string. Enfin une méthode avec un jeu complet de contraintes :
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 doit être un type référence (class), doit être comparable à lui-même (IComparable<T>) et doit avoir un constructeur public sans paramètre (new()). Les trois faits sont consignés dans les métadonnées, sous une forme précise et sans ordre que le décompilateur doit reconvertir en une clause where légale et ordonnée.
Où vivent réellement les données
Deux tables de métadonnées et un masque de bits portent tout.
La table GenericParam a une ligne par paramètre de type, pour les types comme pour les méthodes. Chaque ligne stocke la position du paramètre (base zéro), son nom, son propriétaire et une colonne Flags avec deux groupes de bits indépendants :
- Le
VarianceMask(deux bits de poids faible) :0x0non variant,0x1covariant (out),0x2contravariant (in). Jamais non nul sauf sur les paramètres d”interfaces et de délégués. - Le
SpecialConstraintMask:0x04contrainte de type référence (class),0x08contrainte de type valeur / non nullable (struct),0x10contrainte de constructeur par défaut (new()).
La table GenericParamConstraint a une ligne par contrainte de type, chacune appariant une ligne GenericParam à un TypeDefOrRef — la classe de base ou l”interface à laquelle le paramètre est lié. where T : SomeBase, IComparable<T> produit deux lignes pour le même paramètre : une pointant vers SomeBase, une vers IComparable<T>.
Rendue comme ILDasm l”affiche, l”interface covariante place +T directement dans l”en-tête de la classe :
.class interface public abstract auto ansi IProducer`1<+T>
{
.method public hidebysig newslot abstract virtual
instance !T Produce() cil managed
{
}
}
Le +T est le bit covariant du VarianceMask ; un paramètre contravariant se rend par -T, et un non variant par un simple T. Le 1 est l”arité. Pour la méthode contrainte, ILDasm imprime la contrainte de type de base entre parenthèses avant le nom du paramètre et les contraintes spéciales comme mots-clés sur le paramètre :
.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
{
}
Lisez cet en-tête avec soin : class et .ctor sont les bits du SpecialConstraintMask (class + new()), le ([mscorlib]System.IComparable1<!!T>)entre parenthèses est l''unique ligneGenericParamConstraint, et T` est le nom du paramètre. Rien de tout cela n”apparaît dans une instruction : ce sont des métadonnées purement déclaratives attachées à la signature de la méthode.
La subtilité de notnull et unmanaged
Les trois contraintes spéciales classiques possèdent chacune un bit du SpecialConstraintMask. Les deux modernes non, car le masque de bits a été gelé avant qu”elles existent, et elles sont encodées indirectement :
notnullest transporté par un attribut deSystem.Runtime.CompilerServicessur leGenericParam, sans aucun indicateur de contrainte spéciale activé. Un lecteur qui ne consulte que leSpecialConstraintMaskne voit rien et émet un paramètre sans contrainte, perdant la contrainte entièrement.unmanagedest encodé comme l”indicateurstruct(0x08) plus une ligneGenericParamConstraintdont le type porte un modificateur requis — unmodreqdeSystem.Runtime.CompilerServices.IsUnmanagedAttribute— sur une contrainteSystem.ValueType. Un lecteur qui ne voit que le bitstructémetwhere T : struct, qui est une contrainte strictement plus faible qu”unmanagedet laisse passer en silence des types valeur gérés.
En IL, le cas unmanaged ressemble à un paramètre marqué struct avec une contrainte ValueType dotée d”un modreq :
.method ... static void Blit<valuetype .ctor
(class [System.Runtime]System.ValueType modreq(
[System.Runtime]System.Runtime.CompilerServices.IsUnmanagedAttribute)) T>
(...) cil managed
La présence du modreq(...IsUnmanagedAttribute) est toute la différence entre unmanaged et struct. Glass le vérifie explicitement, de même que l”attribut notnull, de sorte qu”aucune des deux contraintes modernes n”est perdue ni élargie.
Ce que produit un décompilateur naïf
Un décompilateur bâti autour des corps de méthode n”a ici rien à quoi s”accrocher, car rien de tout cela n”est dans un corps de méthode. Deux modes de défaillance dominent, et tous deux sont silencieux.
Variance perdue. L”interface revient invariante :
// FAUX — le out a disparu
public interface IProducer<T>
{
T Produce();
}
Cela compile. Son unique membre est identique. Mais elle n”est plus covariante, donc toute affectation d”IProducer<string> vers IProducer<object> que les appelants ont écrite contre l”original échoue désormais à la compilation contre l”interface régénérée. Rien dans IProducer elle-même ne semble faux ; la cassure n”apparaît qu”aux sites d”affectation, fréquemment dans d”autres assemblys.
Mauvais ordre des contraintes. Un décompilateur qui parcourt les métadonnées et ajoute les mots-clés au fur et à mesure émet quelque chose comme :
// FAUX — ne compile pas
public static T CreateAndRank<T>(IEnumerable<T> items)
where T : IComparable<T>, class, new(), struct
{ ... }
C# le rejette d”emblée. La contrainte class doit venir en premier, new() doit venir en dernier, class et struct sont mutuellement exclusives, et les contraintes d”interface tiennent le milieu. Les métadonnées ne conservent rien de cet ordre — les lignes GenericParamConstraint sont dans un ordre arbitraire et les bits d”indicateur n”ont aucun ordre — donc le décompilateur doit imposer le bon ordre plutôt que répéter ce qu”il a lu.
La figure ci-dessous montre les deux sources de métadonnées alimentant le trieur de contraintes de Glass, qui range chaque contrainte par catégorie et émet les groupes dans l”ordre fixe de C# :
Comment Glass le reconstruit
Glass traite les paramètres génériques comme une affaire de métadonnées de première classe, distincte de la décompilation des méthodes. Pour chaque paramètre — de type ou de méthode — il exécute un pipeline explicite :
- Lire le
VarianceMaskdepuisGenericParam.Flags. Covariant devientout, contravariant devientin, et le mot-clé se place dans la déclaration (IProducer<out T>), pas dans la clausewhere. La variance n”est lue que pour les paramètres d”interfaces et de délégués, où elle est légale. - Lire le
SpecialConstraintMaskpour les bitsclass,structetnew()— mais avant de se fier à un bitstructnu, vérifier si la contrainteValueTypeporte le modreqIsUnmanagedAttribute, auquel cas la contrainte estunmanaged, et nonstruct. Vérifier aussi les attributs duGenericParampour le marqueurnotnull. - Lire chaque ligne
GenericParamConstraintdu paramètre et résoudre chaqueTypeDefOrRefen une classe de base ou une interface. Un type classe va dans le groupe type de base ; les interfaces vont dans le groupe interfaces. - Trier dans l”ordre fixe de C# et émettre : la contrainte
class/struct/unmanaged/notnullen premier, puis le type de base, puis les interfaces, puisnew()en dernier. Les groupes vides sont omis, et un paramètre sans aucune contrainte n”obtient pas de clausewhere.
Le résultat est le code source dont vous êtes parti, équivalent contrat pour contrat :
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;
}
Parce que les mots-clés class et new() sont venus du SpecialConstraintMask et qu”IComparable<T> est venu de la ligne GenericParamConstraint, et parce que le trieur les a placés dans l”ordre qu”exige C#, la méthode recompile avec exactement les contraintes que l”original déclarait — ni plus fortes, ni plus faibles, et dans un ordre que le compilateur accepte.
Une interface covariante, aller-retour
La reconstruction de l”interface est plus courte mais montre le chemin de la variance de bout en bout. Glass lit le bit covariant +T de l”unique ligne GenericParam et réémet le out :
public interface IProducer<out T>
{
T Produce();
}
Le out est de retour dans la déclaration, donc IProducer<string> est de nouveau assignable à IProducer<object>. Si Glass n”avait lu que les signatures des membres et sauté le VarianceMask, l”interface serait revenue invariante et toute affectation variante en aval aurait cassé — le genre de perte invisible dans le type lui-même et qui ne remonte qu”aux sites d”appel.
L”exécuter
Pointez Glass sur l”assembly et demandez l”unique type. Notez l”arité à accent grave dans le nom du type :
glass decompile MyApp.dll --type MyApp.Abstractions.IProducer`1
La sortie est l”interface annotée out ci-dessus. Pour décompiler le type qui déclare la méthode contrainte et confirmer que les contraintes font l”aller-retour :
glass decompile MyApp.dll --type MyApp.Ranking.Ranker --members
Pour balayer un espace de noms entier et vérifier que la variance et les contraintes de chaque paramètre générique survivent à la reconstruction :
glass decompile MyApp.dll --namespace MyApp.Abstractions --constraint-check
--constraint-check relit les tables GenericParam et GenericParamConstraint après la reconstruction et vérifie que les mots-clés de variance, les contraintes spéciales et les liaisons de type de la clause where émis correspondent exactement aux métadonnées — de sorte que vous obtenez une garantie, pas un espoir, que les signatures régénérées portent le même contrat que l”original. Glass est un décompilateur libre et ouvert ; ceci est une sortie standard, rien de restreint.
Pourquoi cela vaut la peine dӐtre parfaitement exact
La variance et les contraintes sont la partie d”un type générique que ses utilisateurs voient. Un out perdu ne change pas les membres d”IProducer, mais il révoque en silence une promesse d”assignabilité sur laquelle d”autre code était construit. Une clause where mal ordonnée ne change pas le sens de T, mais elle empêche le fichier de compiler. Et un struct émis là où les métadonnées disaient unmanaged relâche une garantie sans le moindre avertissement, car la contrainte plus faible accepte encore tout ce qu”acceptait la plus stricte et davantage.
La règle que suit Glass est donc la même que celle qui gouverne la disposition des structures : ceci est des métadonnées, les métadonnées sont la vérité de référence, et la reconstruction les lit plutôt que de deviner. Lire le VarianceMask, lire le SpecialConstraintMask (et le modreq et l”attribut qui se cachent derrière un bit struct nu), lire chaque ligne GenericParamConstraint et tout réémettre dans le seul ordre que C# accepte. Quand vous inspectez ou déboguez du code générique que vous avez le droit de lire, cette fidélité est la différence entre une signature à laquelle vous pouvez vous fier et une qui change le contrat en silence.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.