Décompiler les surcharges d'opérateurs : noms op_, opérateurs checked et les conversions qui ne compilent que comme opérateurs
Un opérateur défini par l'utilisateur est une méthode statique avec un nom réservé et l'indicateur specialname — op_Addition, op_Implicit, op_Equality — et le ramener à une déclaration d'opérateur ressemble à une recherche dans une table. Trois choses le rendent plus difficile que la table. Les conversions définies par l'utilisateur peuvent être surchargées sur le seul type de retour, ce qui est de l'IL légal et du C# illégal : deux méthodes op_Explicit aux paramètres identiques ne diffèrent que par ce qu'elles renvoient, de sorte qu'un décompilateur qui les émet comme méthodes ordinaires produit une source qui ne peut pas compiler. C# 11 a ajouté les opérateurs checked — op_CheckedAddition, op_CheckedExplicit — qui doivent être rendus avec le mot-clé checked et appariés à leur jumeau unchecked, sinon chaque site d'appel checked(a + b) se décompile en appel de méthode. Et op_True/op_False, op_Increment et les opérateurs d'affectation composée d'instance de C# 14 compilent chacun en motifs d'appel qu'il faut replier en syntaxe plutôt qu'afficher comme invocations. Ce billet lit l'IL de chacun, montre les sites d'appel qui guident la reconstruction, et parcourt la façon dont Glass.NET les retransforme en opérateurs tels que l'auteur les a écrits.
Les opérateurs ressemblent à la partie facile de la décompilation. Le compilateur C# transforme a + b sur un type défini par l’utilisateur en appel à une méthode statique nommée op_Addition ; la méthode porte l’indicateur specialname ; un décompilateur maintient une table des noms réservés vers les jetons d’opérateur et émet operator +. Terminé.
La table est nécessaire, c’est là que la plupart des décompilateurs s’arrêtent, et trois fonctionnalités du langage se trouvent juste au-delà. Les conversions définies par l’utilisateur peuvent être surchargées purement sur le type de retour — IL légal, illégal pour une méthode C# ordinaire, donc les émettre comme méthodes produit une source qui ne compilera pas. Les opérateurs checked de C# 11 sont des méthodes distinctes avec un infixe Checked, sélectionnées au site d’appel par le contexte, et doivent être rendues avec le mot-clé checked et appariées à leurs jumeaux unchecked, sinon la sémantique de débordement de l’auteur s’évanouit. Et une poignée d’opérateurs — true/false, ++ et les affectations composées d’instance de C# 14 — compilent en formes d’appel qu’il faut replier en expressions, pas afficher comme invocations. Ce billet lit l’IL de chacun et la façon dont Glass.NET l’inverse.
Le cas de la table : static, specialname, nom réservé
Voici le type sur lequel travaille le reste du billet — un montant monétaire en centimes avec les opérateurs qu’un vrai type aurait :
public readonly struct Money : IEquatable<Money>
{
public long Cents { get; }
public Money(long cents) => Cents = cents;
public static Money operator +(Money a, Money b) => new(unchecked(a.Cents + b.Cents));
public static bool operator ==(Money a, Money b) => a.Cents == b.Cents;
public static bool operator !=(Money a, Money b) => a.Cents != b.Cents;
public bool Equals(Money other) => Cents == other.Cents;
public override bool Equals(object? o) => o is Money m && Equals(m);
public override int GetHashCode() => Cents.GetHashCode();
}
operator + se compile en une méthode statique dont le nom est fixé par la spécification de la CLI et dont l’indicateur specialname dit aux outils « ceci n’est pas une méthode ordinaire » :
.method public hidebysig specialname static
valuetype Money op_Addition(valuetype Money a, valuetype Money b) cil managed
{
ldarga.s a
call instance int64 Money::get_Cents()
ldarga.s b
call instance int64 Money::get_Cents()
add // unchecked: wraps on overflow
newobj instance void Money::.ctor(int64)
ret
}
Et une utilisation, var total = price + tax;, se compile en un appel ordinaire :
ldloc.0 // price
ldloc.1 // tax
call valuetype Money Money::op_Addition(valuetype Money, valuetype Money)
stloc.2 // total
Trois choses identifient l’opérateur, et un décompilateur a besoin des trois. Le nom op_Addition est réservé, mais un auteur C# peut déclarer une méthode ordinaire portant ce nom — le compilateur l’autorise, elle ne peut simplement pas être invoquée avec +. L’indicateur specialname est ce qui distingue le vrai opérateur ; une méthode nommée op_Addition sans lui doit être rendue comme méthode, sinon la source décompilée change de sens. Et l’arité compte : op_Addition à deux paramètres est le + binaire, tandis que op_UnaryPlus à un seul est le + unaire ; op_Subtraction et op_UnaryNegation de même, donc la table est indexée par nom et nombre de paramètres. Glass vérifie les trois, puis fait ce qui rend la sortie lisible : à chaque call vers un opérateur reconnu, il replie l’appel dans une expression, de sorte que la locale vaut price + tax et non Money.op_Addition(price, tax).
La paire d’égalité a une règle supplémentaire : C# exige que == et != soient déclarés ensemble, et un décompilateur qui reconnaît l’un et pas l’autre émet une source qui ne compile pas. Il en va de même pour </>, <=/>= et true/false. Glass rend la paire comme paire.
Conversions : la surcharge que C# ne peut pas exprimer comme méthode
Ajoutez les conversions qu’un type monétaire veut — rétrécissement vers int et long, élargissement depuis int :
public static explicit operator int(Money m) => (int)m.Cents;
public static explicit operator long(Money m) => m.Cents;
public static implicit operator Money(int cents) => new(cents);
Les trois compilent en noms réservés : op_Explicit pour les conversions explicites, op_Implicit pour l’implicite. Lisez attentivement les deux premières :
.method public hidebysig specialname static int32 op_Explicit(valuetype Money m) cil managed
.method public hidebysig specialname static int64 op_Explicit(valuetype Money m) cil managed
.method public hidebysig specialname static valuetype Money op_Implicit(int32 cents) cil managed
Deux méthodes, même nom, même paramètre unique, type de retour différent. Dans la CLI, la signature d’une méthode inclut son type de retour, donc ce sont deux méthodes distinctes et les métadonnées contiennent les deux sans broncher. En C#, une méthode ordinaire est identifiée par son nom et ses types de paramètres seulement ; déclarer static int op_Explicit(Money m) et static long op_Explicit(Money m) côte à côte est l’erreur CS0111, membre en double. La seule syntaxe C# qui permet à deux membres de ne différer que par le type de retour est l’opérateur de conversion, où le type cible fait partie de ce que vous déclarez.
Pour les conversions, le rendu en opérateur n’est donc pas un choix de lisibilité — un décompilateur qui ne reconnaît pas op_Explicit et émet les deux méthodes littéralement produit une source qui ne peut pas compiler du tout, et l’erreur est déroutante parce que les deux méthodes ont l’air parfaitement normales prises isolément.
Les sites d’appel sont là où le type de retour est choisi. (int)m et (long)m appellent tous deux une méthode nommée op_Explicit avec le même argument ; la seule différence est la signature dans le jeton d’appel :
ldloc.0
call int32 Money::op_Explicit(valuetype Money) // (int)m — resolves to the int32-returning one
...
ldloc.0
call int64 Money::op_Explicit(valuetype Money) // (long)m — resolves to the int64-returning one
Glass résout chaque jeton d’appel vers sa méthode exacte — type de retour inclus — et émet le cast avec le bon type cible. La direction inverse est plus subtile. Money m = 5; n’a aucun cast dans la source ; la conversion implicite est invisible. En IL, elle est aussi visible que n’importe quel appel :
ldc.i4.5
call valuetype Money Money::op_Implicit(int32)
stloc.0
Un décompilateur peut légalement rendre ceci comme Money m = (Money)5; — cela compile, puisqu’une conversion implicite peut toujours être invoquée explicitement — mais ce n’est pas ce que l’auteur a écrit, et cela cache le fait que la conversion est implicite. Glass élide l’appel quand le type cible rend la conversion non ambiguë, restituant Money m = 5;, et ne conserve un cast explicite que lorsque le retirer changerait la résolution de surcharge à cet endroit.
Opérateurs checked : la même expression, une méthode différente
C# 11 a permis à un type de dire ce que fait + quand le contexte environnant est checked. Le type monétaire gagne un jumeau vérifié contre le débordement pour l’addition et pour la conversion rétrécissante :
public static Money operator checked +(Money a, Money b) => new(checked(a.Cents + b.Cents));
public static explicit operator checked int(Money m) => checked((int)m.Cents);
Chacun se compile en une méthode séparée avec Checked inséré dans le nom réservé :
.method public hidebysig specialname static
valuetype Money op_CheckedAddition(valuetype Money a, valuetype Money b) cil managed
{
ldarga.s a
call instance int64 Money::get_Cents()
ldarga.s b
call instance int64 Money::get_Cents()
add.ovf // the one instruction that differs from op_Addition
newobj instance void Money::.ctor(int64)
ret
}
.method public hidebysig specialname static int32 op_CheckedExplicit(valuetype Money m) cil managed
{
ldarga.s m
call instance int64 Money::get_Cents()
conv.ovf.i4 // vs conv.i4 in the unchecked op_Explicit
ret
}
L’ensemble complet suit le même motif : op_CheckedSubtraction, op_CheckedMultiply, op_CheckedDivision, op_CheckedUnaryNegation, op_CheckedIncrement, op_CheckedDecrement et op_CheckedExplicit. La règle du langage est qu’un opérateur checked ne peut être déclaré que si la forme unchecked existe aussi — operator checked + exige operator + — parce que le compilateur doit toujours avoir une méthode à appeler dans un contexte unchecked.
Quelle méthode est appelée se décide entièrement au site d’utilisation, par le contexte :
var a = price + tax; // unchecked context (the default)
var b = checked(price + tax); // checked expression
checked { var c = price + tax; } // checked block
call valuetype Money Money::op_Addition(valuetype Money, valuetype Money) // a
call valuetype Money Money::op_CheckedAddition(valuetype Money, valuetype Money) // b
call valuetype Money Money::op_CheckedAddition(valuetype Money, valuetype Money) // c
Deux problèmes de reconstruction s’ensuivent. Côté déclaration, un décompilateur qui ne connaît que les noms antérieurs à C# 11 voit op_CheckedAddition comme une méthode specialname non reconnue et émet public static Money op_CheckedAddition(Money a, Money b) — ce qui compile, et qui est faux, parce que le type a silencieusement perdu son comportement checked : un checked(a + b) recompilé appelle désormais le op_Addition qui déborde en boucle. Côté utilisation, chaque checked(price + tax) de l’original devient Money.op_CheckedAddition(price, tax) dans la sortie, un appel de méthode là où l’auteur avait écrit une expression.
Glass reconnaît la famille op_Checked* comme opérateurs, émet operator checked + et explicit operator checked int avec le mot-clé en place, et à chaque site d’appel vers une méthode checked enveloppe l’expression repliée dans checked(...) — ou, quand une suite d’instructions consécutives appellent toutes des formes checked, dans un bloc checked { }, qui est la forme que l’auteur a le plus probablement écrite. Les types intégrés donnent à la même discipline un visage familier : add.ovf sur deux int se décompile en checked(x + y), et le cas défini par l’utilisateur est la même règle appliquée à une méthode nommée au lieu d’une instruction.
L’opérateur arithmétique le plus récent, >>> de C# 11, est une version plus petite de la même leçon : pour les types intégrés c’est l’instruction shr.un, et pour les types définis par l’utilisateur c’est op_UnsignedRightShift, un nom qui n’existait pas dans la table avant 2022 et qu’un décompilateur plus ancien rend comme un appel.
true, false et le développement du court-circuit
operator true et operator false se compilent en op_True et op_False, et ce sont les seuls opérateurs que le compilateur appelle à votre place sans opérateur dans la source. Un type monétaire qui traite zéro comme faux :
public static bool operator true(Money m) => m.Cents != 0;
public static bool operator false(Money m) => m.Cents == 0;
public static Money operator &(Money a, Money b) => new(Math.Min(a.Cents, b.Cents));
Un if (balance) se compile en un appel que l’auteur n’a jamais écrit :
ldloc.0
call bool Money::op_True(valuetype Money)
brfalse.s SKIP
Glass replie l’appel à op_True dans la condition — if (balance), pas if (Money.op_True(balance)). Le développement de && est le plus intéressant, parce qu’il est défini en termes de false et de & : a && b sur un type utilisateur avec ces opérateurs signifie « si a est faux, le résultat est a ; sinon a & b » :
ldloc.0 // a
dup
call bool Money::op_False(valuetype Money)
brtrue.s RESULT_IS_A // a is "false" → short-circuit, leave a on the stack
ldloc.1 // b
call valuetype Money Money::op_BitwiseAnd(valuetype Money, valuetype Money)
RESULT_IS_A:
Un décompilateur qui rend les opérateurs mais pas ce motif produit quelque chose comme Money.op_False(a) ? a : (a & b), ce qui est correct et illisible. Glass reconnaît la forme dup / op_False / brtrue / op_BitwiseAnd et émet a && b. La forme || est symétrique sur op_True et op_BitwiseOr.
Incrément, et l’opérateur qui n’est pas statique
operator ++ se compile en op_Increment, une méthode statique qui renvoie une nouvelle valeur ; le compilateur stocke le résultat dans la variable. balance++ donne :
ldloc.0
call valuetype Money Money::op_Increment(valuetype Money)
stloc.0
Rendu littéralement, c’est balance = Money.op_Increment(balance);. La forme idiomatique est balance++, et la distinction préfixe/suffixe se récupère selon que la valeur d’avant incrément est conservée sur la pile (un dup avant l’appel) — Glass lit cela pour choisir ++balance ou balance++. Il en va de même pour l’affectation composée sur les types qui ne définissent que l’opérateur statique : balance += tax est littéralement balance = balance + tax en IL — un appel à op_Addition et un stockage — et Glass réécrit la forme charge-appel-stockage-au-même-emplacement en +=.
C# 14 change ce dernier cas. Un type peut désormais déclarer la forme composée directement, comme méthode d’instance qui modifie le receveur sur place :
public void operator +=(Money other) => _cents += other.Cents; // C# 14 instance operator
Dans les métadonnées, c’est différent de tous les opérateurs précédents : c’est une méthode d’instance, elle renvoie void, et elle est marquée pour qu’un compilateur qui ne la comprend pas refuse le type au lieu de le mal lire :
.method public hidebysig specialname instance void op_AdditionAssignment(valuetype Money other) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string)
= {string('UserDefinedCompoundAssignmentOperators')}
...
}
Un balance += tax contre ce type se compile en ldloca.s balance; ldloc tax; call instance void Money::op_AdditionAssignment(Money) — sans stockage, parce que la méthode a modifié balance via le pointeur managé. Chaque heuristique de l’ancien manuel le manque : le détecteur qui exige static voit une méthode ordinaire ; le réécriveur d’affectation composée qui cherche charge-appel-stockage ne trouve aucun stockage. La sortie est balance.op_AdditionAssignment(tax);, qui compile et se lit comme un bogue. Glass reconnaît la famille op_*Assignment d’instance avec le marqueur CompilerFeatureRequired, rend la déclaration comme public void operator +=(Money other), et replie le site d’appel en balance += tax.
Le lire comme une seule règle
Le fil conducteur est que les opérateurs sont une convention de nommage plus un indicateur au niveau des métadonnées, et que tout ce qui concerne leur apparence dans la source — le symbole, le mot-clé checked, le cast implicite qui n’est pas écrit, le && qui se développe en trois appels, le += qui est désormais une méthode d’instance mutatrice — est reconstruit à partir de la forme du site d’appel, pas de la méthode seule. Un décompilateur qui s’arrête à la table des noms vous livre une source qui compile mais est fausse pour les opérateurs checked, une source non compilable pour les conversions surchargées par type de retour, et des appels de méthode là où l’auteur a écrit de l’arithmétique. Glass.NET lit l’indicateur, le nom, l’arité, la signature complète du jeton d’appel et le motif d’instructions environnant ensemble, ce qu’il faut pour vous restituer operator checked +, explicit operator long, if (balance) et balance += tax tels que l’auteur les a écrits.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.