Décompiler les tuples : où vivent réellement les noms (count, name)
Un ValueTuple n'a pas de champ nommé count ni name — ce sont Item1 et Item2, et le système de types ne sait rien de vos noms. Les noms que vous avez écrits vivent dans un attribut [TupleElementNames] que le compilateur attache à la signature de méthode, au champ ou à la propriété, sous forme d'un tableau de chaînes avec des null aux positions sans nom. Un décompilateur qui ignore cet attribut vous rend Item1/Item2 ; un qui le lit restaure (int count, string name). Voici exactement comment les noms de tuple sont encodés, pourquoi ils survivent sur les signatures mais disparaissent sur les variables locales, et comment Glass.NET les remet en place.
Les tuples sont l’un des endroits où la syntaxe de C# et le système de types du CLR divergent en silence, et la divergence est visible dès que vous décompilez. Vous avez écrit (int count, string name) ; le runtime n’a jamais entendu parler de count ni de name. Le tuple est System.ValueTuple<int, string>, et ses deux champs sont Item1 et Item2 — fixes, génériques, anonymes. Alors où sont passés vos noms ? Ils sont réels, ils survivent à la compilation, et ils vivent à un endroit surprenant : dans un attribut boulonné sur la signature, pas du tout dans le type.
Cette séparation explique pourquoi deux décompilateurs peuvent diverger sur la même méthode — l’un montre (int count, string name) et l’autre ValueTuple<int, string> avec Item1/Item2 — et pourquoi les noms de tuple reviennent proprement sur un type de retour mais s’évaporent sur une variable locale. Voici une visite pratique de l’encodage : ce que contient [TupleElementNames], comment les tuples imbriqués s’aplatissent, où les noms ne sont pas stockés du tout, et comment Glass.NET les relit vers le tuple que vous avez tapé.
Le code source
Une méthode dont la signature porte des noms à la fois sur son paramètre et son retour, plus un tuple imbriqué pour montrer l’aplatissement, plus un élément délibérément sans nom :
public static class Stats
{
public static (int count, string name) Summarize((int id, string label) input)
{
var scratch = (input.id, input.label.Trim()); // a LOCAL tuple
return (scratch.Item1, scratch.Item2);
}
public static (int id, (string first, string last) name) Split(string full) =>
(1, (full.Split(' ')[0], full.Split(' ')[1]));
public static (int count, string) Partial() => (0, "unnamed"); // mixed
}
Où sont réellement les noms
Compilez Summarize et regardez les métadonnées. Le type de retour est System.ValueTuple\2<int32, string>— voscountetnamen'y sont nulle part. À la place, le compilateur attache unTupleElementNamesAttribute` au retour, portant un tableau de chaînes parallèle aux positions du tuple :
.method public hidebysig static valuetype [System.Runtime]System.ValueTuple`2<int32, string>
Summarize(valuetype [System.Runtime]System.ValueTuple`2<int32, string> input) cil managed
{
// on the RETURN:
.param [0]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.TupleElementNamesAttribute::.ctor(string[])
= { string[2]('count', 'name') }
// on the PARAMETER 'input':
.param [1]
.custom instance void ...TupleElementNamesAttribute::.ctor(string[])
= { string[2]('id', 'label') }
...
}
Donc le mappage des noms est : l’indice du tableau de l’attribut est la position du tuple. ["count", "name"] sur le retour signifie Item1 → count, Item2 → name. L’attribut est émis là où les noms forment un contrat visible — retours, paramètres, champs, propriétés — pour qu’un appelant dans un autre assembly puisse s’y lier. Le type reste ValueTuple ; seul l’attribut porte votre intention.
Les tuples imbriqués s’aplatissent avec des null
Split retourne (int id, (string first, string last) name) — un tuple dont le second élément est lui-même un tuple. Il n’y a pas d’attribut imbriqué ; les noms sont aplatis en un tableau par un parcours préfixe, avec un null à chaque position qui est un nœud composite (non-feuille) :
.custom instance void ...TupleElementNamesAttribute::.ctor(string[])
= { string[4]('id', nullref, 'first', 'last') }
Lisez-le comme un parcours d’arbre : id est la première feuille, null marque le conteneur du tuple imbriqué lui-même, puis first/last appartiennent au tuple intérieur et id à l’extérieur. Le décompilateur reconstruit la forme en parcourant l’arbre de types ValueTuple et en consommant les noms positionnellement, donc il sait que first/last appartiennent au tuple intérieur et id à l’extérieur. L’invariant clé : la longueur du tableau est égale au nombre de positions à travers la forme aplatie, et le lecteur utilise la structure du type, pas le tableau seul, pour ré-imbriquer.
Les éléments mélangés et sans nom utilisent null
Partial retourne (int count, string) — un nommé, un pas. Les positions sans nom sont stockées comme null :
.custom instance void ...TupleElementNamesAttribute::.ctor(string[])
= { string[2]('count', nullref) }
Donc ["count", null] se reconstruit en (int count, string) — Item1 obtient son nom, Item2 se rabat sur le Item2 positionnel, qui est exactement par quoi vous y accédez dans le code. C’est pourquoi un décompilateur doit traiter null comme « pas de nom ici », pas comme une chaîne vide ou une erreur.
Ce que montre un décompilateur naïf
Un décompilateur qui lit le type mais pas l’attribut est correct sur la forme et aveugle aux noms. Chaque tuple devient ValueTuple<…> et chaque accès est Item1/Item2 :
// Naive output: names dropped, contract obscured.
public static ValueTuple<int, string> Summarize(ValueTuple<int, string> input)
{
ValueTuple<int, string> scratch = new ValueTuple<int, string>(input.Item1, input.Item2.Trim());
return new ValueTuple<int, string>(scratch.Item1, scratch.Item2);
}
public static ValueTuple<int, ValueTuple<string, string>> Split(string full) => …;
Ça compile vers la même chose, mais il a jeté la seule information qui rendait la signature auto-documentée — et un appelant qui lit ceci ne peut plus dire que Item1 est un compte et Item2 un nom.
Ce que Glass reconstruit
Glass est bâti sur ICSharpCode.Decompiler, le moteur d’ILSpy, qui lit TupleElementNamesAttribute sur chaque signature et remappe les noms positionnellement — restaurant la forme imbriquée depuis le tableau aplati, et laissant les positions sans nom comme positionnelles :
// Glass output: the signatures you wrote.
public static (int count, string name) Summarize((int id, string label) input)
{
(int, string) scratch = (input.id, input.label.Trim());
return (scratch.Item1, scratch.Item2);
}
public static (int id, (string first, string last) name) Split(string full) => …;
public static (int count, string) Partial() => (0, "unnamed");
Remarquez ce qui est revenu et ce qui ne l’est pas. Les signatures — retour et paramètre de Summarize, le retour imbriqué de Split, le retour mélangé de Partial — sont exactement comme écrites, car l’attribut les a préservées. Mais scratch, la locale, est reconstruite comme un (int, string) ordinaire accédé via Item1/Item2. Ce n’est pas une limite de Glass ; c’est la vérité des métadonnées. Le compilateur n’émet aucun TupleElementNamesAttribute pour une variable locale, car les noms d’éléments d’une locale ne sont pas un contrat que quiconque hors du corps de la méthode peut voir — rien dans l’assembly n’a jamais enregistré que vous les avez appelés id et label à l’intérieur de la méthode. Glass montre exactement ce qui a survécu et n’invente rien.
Quand descendre dans la vue IL
L’encodage de l’attribut est l’un des cas les plus clairs où voir les métadonnées brutes explique le C# décompilé :
- Pourquoi un nom est-il revenu ici mais pas là ? Basculez en IL et cherchez le
.paramportantTupleElementNamesAttribute. Présent sur le retour → noms restaurés ; absent (une locale) →Item1/Item2. La présence ou l’absence de l’attribut est l’explication. - Lire une forme imbriquée. Le tableau aplati avec ses marqueurs
nulln’est visible qu’en IL. Quand un tuple imbriqué reconstruit paraît surprenant, le tableau dans l’attribut est la vérité de terrain de quel nom appartient à quelle position. - Liaison inter-assembly. Comme les noms vivent sur la signature, un consommateur dans un autre assembly se lie à
.count/.namepurement à partir de cet attribut. Confirmer qu’il est émis est la façon de vérifier qu’une bibliothèque expose vraiment l’API de tuple nommé que vous vouliez.
Lisez le C# pour récupérer les signatures nommées ; descendez dans l’IL pour voir que les noms n’ont jamais été dans le type — ils étaient un attribut depuis le début, et Item1/Item2 sur une locale sont les métadonnées qui vous disent la vérité.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.