Décompiler le mot-clé dynamic : lire l'IL des call sites dans Glass.NET
Une ligne de C# avec dynamic se compile en un call site mis en cache, un binder, un tableau de drapeaux d'information d'argument et une invocation à travers un champ délégué — une douzaine de membres d'échafaudage généré pour un seul appel de méthode. Voici ce que le compilateur émet réellement pour le dispatch dynamique, comment un décompilateur naïf vous montre la machinerie CallSite brute, et comment Glass.NET reconnaît le motif et vous rend la ligne que vous avez écrite.
Écrivez une ligne de C# contenant dynamic et le compilateur écrit un petit programme à votre place. Il n’y a pas de callvirt vers une méthode nommée Add ; il y a un call site mis en cache, un binder qui décrit ce que « appeler Add » signifie, un tableau de drapeaux décrivant chaque argument, et une invocation à travers un délégué stocké dans un champ généré. Un décompilateur doit voir tout cet échafaudage et comprendre qu’il se résume à l’unique expression que vous avez tapée — sinon il vous remet l’échafaudage et vous laisse rétro-concevoir votre propre source.
Ceci est une plongée concrète exactement là-dessus : ce que le compilateur C# émet pour le dispatch dynamic, à quoi ressemble le lowering brut quand rien ne le replie, et comment Glass.NET reconnaît le motif de call site et reconstruit le dynamic de départ. C’est l’une des démonstrations les plus nettes de la raison pour laquelle « ça décompile » et « ça décompile en quelque chose que vous pouvez lire » sont deux affirmations différentes.
La source
Voici le programme entier. Trois opérations dynamiques : une invocation de membre, un opérateur binaire et une conversion implicite d’un résultat dynamic vers une variable locale typée.
public static class Demo
{
public static int Run(dynamic a, dynamic b)
{
dynamic sum = a.Add(b); // invocation de membre dynamique
dynamic twice = sum + sum; // opérateur binaire dynamique
int result = twice; // conversion dynamic -> int
return result;
}
}
Rien d’exotique. Mais aucune de ces trois opérations ne peut être résolue à la compilation, parce que le compilateur ne sait pas ce que sont a et b. Alors au lieu d’émettre des appels, il émet la machinerie pour les résoudre plus tard, à la première utilisation, contre les types d’exécution qui se présenteront réellement.
Ce que le compilateur émet réellement
Pour chaque opération dynamique, le compilateur génère un call site mis en cache : un champ statique de type CallSite<T> logé dans une classe cachée (nommée <>o__0 et consorts), où T est la signature de délégué de l’opération. La première fois que l’opération s’exécute, un test de nullité initialise paresseusement le site à partir d’un CallSiteBinder construit par une fabrique de Microsoft.CSharp.RuntimeBinder.Binder. Le binder reçoit un ensemble de CSharpBinderFlags et un CSharpArgumentInfo par argument, décrivant comment chaque valeur participe (est-ce un ref, est-ce une constante de compilation, est-ce la cible de l’opération). Puis le délégué Target du site est invoqué avec le site et les arguments.
Décompilée sans reconnaissance du motif — ce que vous obtenez d’un visualiseur qui ne défait que les transformations évidentes — l’invocation de membre a.Add(b) ressort ainsi :
// Lowering brut de : dynamic sum = a.Add(b);
if (<>o__0.<>p__0 == null)
{
<>o__0.<>p__0 = CallSite<Func<CallSite, object, object, object>>.Create(
Microsoft.CSharp.RuntimeBinder.Binder.InvokeMember(
CSharpBinderFlags.None,
"Add",
null,
typeof(Demo),
new CSharpArgumentInfo[]
{
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null),
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)
}));
}
object sum = <>o__0.<>p__0.Target(<>o__0.<>p__0, a, b);
C’est fidèle à l’IL et presque inutilisable à la lecture. Chaque opération dynamique se développe de la même manière — le + devient un site Binder.BinaryOperation avec un ExpressionType.Add, et le int result = twice; devient un site Binder.Convert ciblant typeof(int). Une méthode de trois lignes enfle en trois classes de cache, trois champs de site, trois initialiseurs paresseux et trois invocations .Target(...). La forme est complètement mécanique, et c’est exactement pourquoi elle peut être reconnue et défaite.
Comment Glass le replie
Glass est bâti sur ICSharpCode.Decompiler, le même moteur que derrière ILSpy, et reconstruire dynamic est l’une de ses transformations de motifs. Le décompilateur reconnaît la forme complète du call site — le cache <>o__/<>p__, l’initialisation paresseuse gardée par le test de nullité, l’appel à la fabrique Binder.* et l’invocation .Target(...) correspondante — comme une seule opération logique, lit le binder pour apprendre quelle opération c’est (InvokeMember "Add", BinaryOperation Add, Convert int), lit le tableau CSharpArgumentInfo pour apprendre comment les opérandes ont été passés, et replie le tout dans l’unique expression qui l’a produit. Ouvrez Demo.Run dans la vue décompilée de Glass et vous obtenez exactement ce que vous avez écrit :
public static int Run(dynamic a, dynamic b)
{
dynamic sum = a.Add(b);
dynamic twice = sum + sum;
int result = twice;
return result;
}
L’échafaudage a disparu parce que Glass a compris à quoi il servait. C’est la différence entre un outil qui vous montre de l’IL translittéré en syntaxe à l’allure de C# et un outil qui vous montre de la source.
dynamic contre object, et où la différence est stockée
Il y a une subtilité que la reconstruction doit traiter correctement. Au niveau du système de types, dynamic est System.Object — les deux sont le même type dans les métadonnées, et l’IL ci-dessus manipule de l’object partout. Alors comment Glass sait-il écrire dynamic a et non object a ?
Le compilateur laisse une miette de pain : System.Runtime.CompilerServices.DynamicAttribute. Partout où un dynamic apparaît dans une signature — un paramètre, un champ, un type de retour, même une position à l’intérieur d’un générique comme List<dynamic> — le compilateur attache cet attribut pour marquer quels object étaient réellement dynamic dans la source. Pour les cas imbriqués, il porte une transformation bool[] qui dit quelles positions dans un type composite étaient dynamiques. Glass lit cet attribut et restaure dynamic précisément là où il appartient, retombant sur object là où l’attribut est absent. Passez dans la vue informations d’assembly et vous pouvez voir l’attribut directement sur les membres ; c’est la métadonnée qui rend la distinction effacée récupérable tout court.
Lire le C#, puis descendre à l’IL
Pour la plupart des usages, le dynamic reconstruit est tout ce que vous voulez. Mais la machinerie de call site encode des détails que l’expression repliée cache délibérément, et quand ces détails comptent vous descendez à la vue IL à côté du C#. Deux valent la peine d’être connus :
- Le passage des arguments. Chaque
CSharpArgumentInfoporte des drapeaux tels queIsRef/IsOutetUseCompileTimeType. Un appeldynamicqui passe un argumentref, ou qui épingle un argument à son type statique plutôt qu’à son type d’exécution, l’enregistre dans le tableau d’informations d’argument. Le C# replié montrefoo.Bar(ref x); l’IL montre lesCSharpArgumentInfoFlagsqui l’ont rendu ainsi. - L’identité de l’opérateur. Une opération binaire dynamique stocke son opérateur sous forme de
System.Linq.Expressions.ExpressionType(Add,LessThan,Equal, …) et d’unCSharpBinderFlagsqui enregistre le contexte, commeCheckedContext. Si vous devez confirmer qu’un+reconstruit a été compilé dans une régionchecked, c’est dans le drapeau du binder en IL que cela vit.
Glass place les deux vues à un clic l’une de l’autre, à dessein. Lisez le C# décompilé pour retrouver l’intention — la ligne que quelqu’un a réellement écrite. Ouvrez l’IL quand vous devez voir quels drapeaux de binder et quels genres d’information d’argument le compilateur a choisis en dessous. Le chemin d’export (glass export --in app.dll --out ./src) écrit le C# reconstruit sur disque sous forme de projet compilable, de sorte que vous pouvez aussi grepper un assembly entier à la recherche des call sites dynamic et les lire dans votre propre éditeur. Dans les deux cas, le gain est le même : une douzaine de membres d’échafaudage de call site généré redeviennent l’unique expression qu’ils représentent, et votre temps va à la compréhension du code plutôt qu’à la comptabilité du compilateur.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.