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

Décompiler les méthodes d'extension : l'attribut [Extension] est la seule trace, le receveur n'était que le premier argument, et l'appel qui semble statique ne l'est pas

Une méthode d'extension n'a aucune existence à l'exécution en tant qu'extension. C'est une méthode statique ordinaire dont le premier paramètre se trouve être marqué, et un appel à celle-ci — text.Trim().Count() — se compile en un appel statique ordinaire, Enumerable.Count(string.Trim(text)), sans syntaxe pointée, sans receveur, sans rien qui dise qu'il a un jour été écrit de façon fluide. La seule chose que le compilateur laisse derrière lui est un attribut [Extension] : sur la méthode, pour que le premier paramètre puisse être restitué avec this, et sur la classe statique qui la contient, pour qu'un outil sache seulement qu'il doit y regarder. Un décompilateur doit lire cet attribut, décider qu'un appel statique dont la cible le porte doit être imprimé comme un appel de membre sur son premier argument, et obtenir la bonne précédence pour qu'une chaîne de dix opérateurs LINQ revienne sous forme de chaîne et non d'imbrication de dix niveaux d'appels statiques. Cet article parcourt l'IL d'une méthode d'extension définie et d'un point d'appel, montre ce que Glass.NET reconstruit, explique pourquoi c'est l'attribut — et non l'appel — qui est la source de vérité, et couvre la forme plus récente des blocs d'extension de C# 14, dont les membres se compilent en les mêmes méthodes statiques sous une autre peau.

Une méthode d’extension est l’un des exemples les plus purs de sucre syntaxique C# qui s’évapore en IL. Vous écrivez this sur le premier paramètre, vous l’appelez sous la forme text.WordCount(), et vous avez l’impression d’avoir ajouté une méthode à string. Le runtime ne voit rien de tout cela. Il y a une méthode statique, il y a un appel statique, et il y a un attribut — [Extension] — laissé derrière comme une note qui dit ceci aurait pu s’écrire avec un point. Tout le travail d’un décompilateur ici consiste à lire cette note et à décider d’imprimer le point, puis à obtenir la bonne précédence pour qu’un pipeline LINQ de dix opérateurs revienne sous forme de pipeline et non d’imbrication à dix niveaux. Cet article le parcourt pour Glass.NET.

La méthode : une méthode statique revêtue d’un seul attribut

Commençons par la définition. Une extension triviale sur string :

public static class StringExtensions
{
    public static int WordCount(this string s)
        => s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}

Compilez-la et regardez ce que this est devenu. Il a disparu du paramètre — s est un paramètre string ordinaire — et à sa place se trouvent deux attributs [Extension], l’un sur la méthode et l’autre sur la classe :

.class public abstract sealed auto ansi beforefieldinit StringExtensions
    extends [System.Runtime]System.Object
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )

    .method public hidebysig static int32 WordCount(string s) cil managed
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )
        // ... ldarg.0; call String::Split(...); ldlen; ...
    }
}

Voilà toute la différence entre une méthode d’extension et un helper statique. La méthode est public static, le premier paramètre est un paramètre normal d’un type normal, il n’y a aucune convention d’appel spéciale. L’attribut sur la méthode est ce qui permet à un décompilateur de restituer le premier paramètre sous la forme this s ; l’attribut sur la classe est ce qui indique à un compilateur consommateur que la classe mérite seulement d’être parcourue à la recherche d’extensions. Retirez les deux et vous avez StringExtensions.WordCount(x) et rien d’autre ; ajoutez-les et x.WordCount() devient légal aussi.

Le point d’appel : identique à l’octet près à un appel statique

Appelons-la maintenant de deux façons et comparons l’IL :

int a = "the quick brown fox".WordCount();         // fluent
int b = StringExtensions.WordCount("the quick brown fox");   // explicit static

Les deux se compilent en les trois mêmes instructions — charger la chaîne, call la méthode statique, stocker :

ldstr      "the quick brown fox"
call       int32 StringExtensions::WordCount(string)
stloc.0

Il n’y a pas de seconde forme. Le CLR n’a aucun concept d’appel d’extension, de sorte que le this est résolu et abandonné à la compilation, et le point d’appel ne garde aucune trace de la syntaxe que l’auteur a utilisée. C’est le nœud de tout le problème : un décompilateur ne peut pas apprendre de l’appel si la source était fluide. Il ne peut que regarder la méthode appelée, y voir [Extension], et choisir d’imprimer l’appel comme un accès à un membre sur son premier argument — "the quick brown fox".WordCount() — parce que c’est la forme idiomatique et presque toujours la forme d’origine. Les deux restitutions sont légales et se compilent de façon identique, le choix relève donc de la lisibilité, pas de la correction. Glass.NET le fait, et c’est pourquoi la sortie se lit comme du C# écrit par un humain plutôt que comme l’expansion d’une macro.

La syntaxe fluide s'évapore ; l'attribut [Extension] la ramènetext.WordCount()StringExtensions.WordCount(text)ldstr textcall WordCount(string)stloc.0métadonnées[Extension] on class + methodSortie de Glasstext.WordCount()IL : une imbrication, l'externe en derniercall ToList(call Select(call Where(source, p), f))chaque opérateur porte [Extension] → déroulersource.Where(p).Select(f).ToList()

La chaîne : une règle, appliquée vers l’extérieur

La raison pour laquelle le point compte, c’est LINQ, où lire l’IL littéralement produit quelque chose de correct et d’illisible. Prenons un pipeline :

var top = people.Where(p => p.Active).Select(p => p.Name).OrderBy(n => n).ToList();

Chacun de Where, Select, OrderBy, ToList est une méthode d’extension statique sur Enumerable, l’IL est donc une imbrication — l’appel le plus externe émis en dernier, chaque résultat alimentant le premier argument de l’appel suivant :

call ...ToList( call ...OrderBy( call ...Select( call ...Where(people, <lambda>), <lambda>), <lambda>))

Lisez cela littéralement et vous obtenez Enumerable.ToList(Enumerable.OrderBy(Enumerable.Select(Enumerable.Where(people, …), …), …)) — une imbrication à quatre niveaux qu’il faut lire de l’intérieur vers l’extérieur. Parce que chaque méthode porte [Extension], Glass.NET applique cette unique règle de façon répétée : imprimer chaque appel statique comme un accès à un membre sur son premier argument. Le Where(people, …) le plus interne devient people.Where(…) ; cette expression entière est le premier argument de Select, elle se lit donc people.Where(…).Select(…) ; et ainsi de suite vers l’extérieur jusqu’à ce que l’imbrication se soit déroulée en la chaîne d’origine. La précédence doit être exacte — un corps de lambda avec une conditionnelle, ou un cast inséré au milieu de la chaîne, exige les bonnes parenthèses sous peine de changer le sens — mais la forme découle directement de l’application de « accès à un membre sur le premier argument » depuis l’appel le plus interne vers l’extérieur.

Pourquoi l’attribut, et non l’appel, est la source de vérité

Il vaut la peine d’être précis sur ce à quoi Glass se fie. Le point d’appel est ambigu par construction — c’est le même IL pour les deux syntaxes — il ne porte donc aucun signal. Les métadonnées de la méthode sont sans ambiguïté : [Extension] est présent ou il ne l’est pas. Glass fonde donc sa décision sur la méthode :

  • La méthode cible porte [Extension] → restituer l’appel sous la forme arg0.Method(rest).
  • La méthode cible ne le porte pas → la restituer sous la forme Type.Method(args).

Cela gère aussi correctement les cas réels délicats. Si quelqu’un a délibérément écrit Enumerable.Where(list, p) sous forme statique explicite, Glass imprime quand même list.Where(p) — ce qui est acceptable, car cela se recompile en le même IL et se lit mieux. Et une méthode d’extension générique comme Select<TSource, TResult> porte l’attribut exactement de la même façon ; Glass restitue src.Select(f) et laisse l’inférence de types de C# retrouver les arguments de type à la recompilation, ne les explicitant que lorsque l’inférence n’y parvient pas. L’attribut est un signal fiable et local sur la méthode appelée, ce qui est exactement ce qu’un décompilateur souhaite — aucune analyse globale, aucune devinette à partir des directives using environnantes, qui ne sont même pas dans l’IL.

La forme en bloc d’extension de C# 14

Du code plus récent peut utiliser des blocs d’extension, qui regroupent des membres sous un seul receveur et, pour la première fois, autorisent des propriétés d’extension :

public static class StringExtensions
{
    extension(string s)
    {
        public int  WordCount => s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
        public bool IsBlank()  => string.IsNullOrWhiteSpace(s);
    }
}

C’est une nouvelle syntaxe, pas un nouveau runtime. Les membres sont toujours abaissés en méthodes statiques sur la classe statique englobante, marquées pour que le compilateur d’un consommateur puisse router name.WordCount et name.IsBlank() vers elles — une propriété d’extension devient une méthode d’accesseur statique, une méthode d’extension une méthode statique avec le receveur comme premier paramètre, plus des métadonnées de regroupement rattachant plusieurs membres à un seul receveur. Un décompilateur qui lit un tel assembly voit des méthodes statiques avec des métadonnées marqueurs et un paramètre receveur, à peu près comme avant. Glass.NET les reconstruit en tant qu’extensions ; une simple méthode d’extension peut être montrée soit sous la forme classique à paramètre this, soit à l’intérieur d’un bloc, mais une propriété d’extension n’a pas d’écriture classique, elle revient donc sous forme de bloc. La sémantique est identique dans les deux cas, parce que les méthodes statiques abaissées sont identiques — c’est la peau qui a changé, pas l’IL.

À retenir

Une méthode d’extension ne laisse exactement qu’une trace dans les métadonnées : l’attribut [Extension], sur la méthode pour que le premier paramètre puisse porter this, et sur la classe pour qu’un compilateur sache qu’il doit y regarder. Le point d’appel ne garde aucune trace — text.WordCount() et StringExtensions.WordCount(text) sont un IL identique à l’octet près — de sorte qu’un décompilateur ne peut pas déduire la forme fluide de l’appel et doit la lire sur la méthode. Glass.NET fait exactement cela : il imprime tout appel statique dont la cible porte [Extension] comme un accès à un membre sur le premier argument, applique cette unique règle depuis l’appel le plus interne vers l’extérieur pour dérouler une imbrication LINQ en chaîne, et reconnaît l’abaissement des blocs d’extension de C# 14 comme les mêmes méthodes statiques sous une syntaxe plus récente. Le résultat se lit comme le pipeline qui a été écrit, pas comme l’imbrication que le compilateur a laissée — parce que le décompilateur s’est fié à l’attribut, qui est la seule chose à avoir survécu.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.