Décompiler les types anonymes : la classe générique générée derrière new { a, b }
Un type anonyme n'a pas de nom que vous ayez écrit, mais c'est une vraie classe dans les métadonnées — une classe générique que le compilateur synthétise, nommée quelque chose comme <>f__AnonymousType0`2, avec une propriété en lecture seule par membre, un constructeur qui les prend toutes, et des redéfinitions de Equals, GetHashCode et ToString. L'astuce qui la rend réutilisable est que la classe générée est générique sur les types de ses propriétés, de sorte que deux sites new { } avec les mêmes noms de propriétés dans le même ordre partagent un seul type sous-jacent instancié avec des arguments de type différents. Cet article lit la classe générée et le site d'appel newobj en IL, montre où vivent réellement les noms des propriétés (sur les propriétés du type généré, pas sur les paramètres de type <Name>j__TPar), parcourt le Equals structurel et le GetHashCode à combinaison de hachage que le compilateur écrit, et explique comment Glass.NET reconnaît le motif et le replie en new { Name = ..., Total = ... } au lieu de vous laisser un type générique imprononçable.
Tapez un new { } en C# et vous créez un objet sans type que vous puissiez nommer. var row = new { Name = customer.Name, Total = customer.Total }; vous donne un row dont vous pouvez lire .Name et .Total, que vous pouvez passer à LINQ et comparer par égalité — mais essayez d’écrire son type et vous ne le pouvez pas, parce qu’il n’y a pas de mot-clé pour cela. Cette absence est une illusion du langage source, exactement comme ?. qui n’a pas d’opcode IL. Dans l’assembly compilé, le type anonyme est une classe parfaitement ordinaire. Le compilateur l’a simplement synthétisée, lui a donné un nom que vous n’êtes pas autorisé à prononcer, et l’a émise à côté de votre code. Bien décompiler un type anonyme consiste à reconnaître cette classe synthétisée pour ce qu’elle est et à la replier dans le new { } qui l’a produite.
La classe que le compilateur écrit
Pour new { Name = ..., Total = ... } le compilateur C# émet une classe sealed générique — un paramètre de type par propriété — qui ressemble, décompilée littéralement, à ceci :
[CompilerGenerated]
[DebuggerDisplay(@"\{ Name = {Name}, Total = {Total} }", Type = "<Anonymous Type>")]
internal sealed class <>f__AnonymousType0<<Name>j__TPar, <Total>j__TPar>
{
private readonly <Name>j__TPar <Name>i__Field;
private readonly <Total>j__TPar <Total>i__Field;
public <Name>j__TPar Name => <Name>i__Field;
public <Total>j__TPar Total => <Total>i__Field;
public <>f__AnonymousType0(<Name>j__TPar Name, <Total>j__TPar Total)
{
<Name>i__Field = Name;
<Total>i__Field = Total;
}
// Equals, GetHashCode, ToString overrides follow — see below.
}
Le backtick-2 dans le vrai nom de métadonnées <>f__AnonymousType0\2signifie deux paramètres de type. Être générique est toute la raison pour laquelle le type est réutilisable : le compilateur le ferme sur des arguments de type concrets à chaque site d'appel, de sorte que chaquenew { }` avec les mêmes noms de propriétés dans le même ordre partage cette unique classe générée.
var a = new { Name = "Acme", Total = 42 }; // <>f__AnonymousType0<string, int>
var b = new { Name = other, Total = count }; // SAME type, same instantiation
var c = new { Name = "x", Total = 1L }; // <>f__AnonymousType0<string, long> — same type, different args
var d = new { Total = 1, Name = "x" }; // DIFFERENT type — order of properties differs
Changez les noms, réordonnez-les, ou ajoutez une propriété et le compilateur génère un type sous-jacent différent (<>f__AnonymousType1, et ainsi de suite). Mêmes noms, même ordre — un seul type partagé, quel que soit le nombre de littéraux que vous avez écrits.
Le site d’appel
Créer un objet anonyme n’a rien d’exotique en IL — c’est un newobj du type générique construit, avec les valeurs des propriétés empilées dans l’ordre de déclaration :
// var row = new { Name = c.Name, Total = c.Total };
ldarg.0
callvirt instance string Customer::get_Name()
ldarg.0
callvirt instance int32 Customer::get_Total()
newobj instance void class '<>f__AnonymousType0'<string, int32>::.ctor(!0, !1)
stloc.0
L’opérande de newobj porte le type générique fermé et la signature de son constructeur (!0, !1) — les deux paramètres de type, liés ici à string et int32. C’est la signature d’un type anonyme au point d’utilisation : un appel de constructeur qui remplit chaque champ readonly d’un coup. Il n’y a pas de chemin par setter, parce que les propriétés sont en lecture seule ; un type anonyme est immuable par construction.
Où vivent réellement les noms
L’erreur facile est de supposer que les noms des propriétés se cachent dans les noms des paramètres de type <Name>j__TPar et <Total>j__TPar, et disparaîtraient donc dans une build release. Ce n’est pas le cas. Ces noms de paramètres de type ne sont que des indices ; les noms faisant autorité sont les vraies lignes de métadonnées Property sur la classe générée — get_Name, get_Total, et les propriétés qui les exposent. Un décompilateur lit les noms directement sur ces lignes, ce qui explique pourquoi new { Name = ..., Total = ... } se reconstruit avec ses membres intacts même sans PDB. Ce qu’un PDB aurait ajouté est le nom de la locale dans laquelle vous avez stocké l’instance (row), pas la forme du type anonyme, qui est entièrement présente dans les métadonnées quoi qu’il arrive.
Les trois redéfinitions
Les types anonymes ont une égalité par valeur, et le compilateur la rend réelle avec trois redéfinitions. Equals est structurel : il convertit l’argument vers le même type générique fermé et compare chaque champ via EqualityComparer<T>.Default.
public override bool Equals(object value)
{
var other = value as <>f__AnonymousType0<<Name>j__TPar, <Total>j__TPar>;
return other != null
&& EqualityComparer<<Name>j__TPar>.Default.Equals(<Name>i__Field, other.<Name>i__Field)
&& EqualityComparer<<Total>j__TPar>.Default.Equals(<Total>i__Field, other.<Total>i__Field);
}
GetHashCode est une combinaison de hachage de forme fixe : il part d’une constante de graine par type que le compilateur incorpore et replie le hachage de chaque champ avec le motif de multiplication par un nombre premier que Roslyn utilise partout.
public override int GetHashCode()
{
int hash = 339618440; // per-type seed baked in by the compiler
hash = hash * -1521134295 + EqualityComparer<<Name>j__TPar>.Default.GetHashCode(<Name>i__Field);
hash = hash * -1521134295 + EqualityComparer<<Total>j__TPar>.Default.GetHashCode(<Total>i__Field);
return hash;
}
ToString construit { Name = ..., Total = ... } avec un StringBuilder, ce que le [DebuggerDisplay] sur la classe reflète aussi. Aucune de ces trois n’est du code que vous avez écrit ; ce sont la signature du compilateur pour « ceci est un type anonyme », et ensemble elles constituent l’essentiel de ce sur quoi un décompilateur s’appuie.
Comment Glass le replie
Aucun signal isolé n’est concluant — une classe sealed générique est ordinaire, et [CompilerGenerated] figure sur de nombreux types synthétisés. Glass.NET fait correspondre le faisceau : le préfixe de nom <>f__AnonymousType, la paire [CompilerGenerated] et [DebuggerDisplay] dont la chaîne de format épelle littéralement les propriétés, les propriétés en lecture seule sur des champs readonly, l’unique constructeur à tous-arguments dans l’ordre de déclaration, et les trois redéfinitions structurelles dans leurs formes exactes de compilateur. Quand tout concorde, Glass supprime entièrement le type synthétisé — vous ne voyez jamais <>f__AnonymousType0 dans la sortie — et réécrit chaque newobj de celui-ci en une projection new { ... }, en prenant les noms de membres des propriétés et les valeurs des arguments du constructeur au site d’appel.
// What Glass renders from the newobj above — the synthesized type is gone.
var row = new { Name = c.Name, Total = c.Total };
Deux subtilités méritent d’être nommées. Quand la valeur de l’initialiseur correspond déjà au nom du membre — new { c.Name } au lieu de new { Name = c.Name } — le compilateur infère le nom de la propriété depuis l’expression, et Glass restitue la forme plus courte d’initialiseur de projection plutôt que d’épeler Name = . Et la syntaxe de requête LINQ s’appuie sur les types anonymes pour les clauses let et les jointures multi-sources, où le compilateur enchaîne une série de types anonymes imbriqués appelés identifiants transparents ; Glass doit dérouler cette imbrication pour récupérer le let d’origine plutôt que d’exposer la plomberie. Dans les deux cas le principe est le même : le type anonyme est une vraie classe que le compilateur a écrite, entièrement décrite dans les métadonnées, et le travail du décompilateur est de la rendre à nouveau imprononçable — de vous rendre le new { } avec lequel vous avez commencé, pas la machinerie en dessous.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.