Skip to content
← Tous les articles
· Delta1 Labs Glass.NETDécompilationC#

Décompiler C# 12 : expressions de collection et constructeurs primaires

Les expressions de collection et les constructeurs primaires ressemblent à des idées syntaxiques uniques, mais le compilateur les abaisse vers un IL très différent selon le type cible et selon qu'un paramètre est capturé ou non. Voici exactement ce qu'émettent `[1, 2, 3]`, les spreads et un constructeur primaire, et où un décompilateur peut replier le sucre face à où il ne peut montrer que la forme abaissée.

C# 12 a ajouté deux fonctionnalités qui se lisent comme des idées uniques dans le code source mais qui se déploient en plusieurs formes très différentes en IL. Une expression de collection — [1, 2, 3], ou [.. premier, .. second] — n’a pas d’opcode propre ; le compilateur choisit un abaissement à partir du type cible, et le choix va d’un simple tableau à un tampon alloué sur la pile ou à un appel vers une méthode constructrice. Un constructeur primaire sur une classe ou un struct ressemble structurellement à celui d’un record, mais s’abaisse vers bien moins. Parcourir les deux abaissements avec précision explique ce qu’un décompilateur comme Glass.NET peut replier dans le sucre, et où il ne peut honnêtement montrer que la forme abaissée.

Expressions de collection : une syntaxe, plusieurs abaissements

Le fait clé à propos de [1, 2, 3] est qu’il est typé par la cible. Les trois mêmes caractères compilent vers un IL différent selon ce à quoi ils sont affectés.

Cible tableau. int[] a = [1, 2, 3]; est exactement un initialiseur de tableau. Pour des éléments primitifs tous constants, le compilateur stocke les octets dans un champ de <PrivateImplementationDetails> et appelle RuntimeHelpers.InitializeArray :

ldc.i4.3
newarr     [System.Runtime]System.Int32
dup
ldtoken    field ... '<PrivateImplementationDetails>'::...    // le blob 1,2,3
call       void RuntimeHelpers::InitializeArray(Array, RuntimeFieldHandle)

Pour des éléments non constants, il émet newarr suivi d’un stelem par case. Dans les deux cas, c’est octet pour octet ce que produit new int[] { 1, 2, 3 }, donc un décompilateur montre une création de tableau — et peut la rendre comme [1, 2, 3] ou comme new int[] { ... } ; les deux sont fidèles car l’IL est identique.

Cible List<T>. List<int> a = [1, 2, 3]; devient une construction plus des appels Add (avec une capacité prédéfinie quand la longueur est connue) :

newobj     instance void List`1<int32>::.ctor()
dup
ldc.i4.1
callvirt   instance void List`1<int32>::Add(!0)
dup
ldc.i4.2
callvirt   instance void List`1<int32>::Add(!0)
...

C’est l’ambiguïté la plus importante de la fonctionnalité : une expression de collection vers List<T> s’abaisse vers le même IL que l’initialiseur de collection classique new List<int> { 1, 2, 3 }. Aucun marqueur ne les sépare. Un décompilateur ne peut donc pas savoir laquelle vous avez écrite et en affichera une — la plupart montrent la forme initialiseur de collection car c’est la syntaxe la plus ancienne et la plus largement compatible. Rien n’est perdu ; simplement la syntaxe de surface peut ne pas correspondre à votre source.

Cible Span<T>/ReadOnlySpan<T>. C’est ici que les expressions de collection gagnent leur place, et où l’IL cesse de ressembler à quoi que ce soit que vous pourriez écrire à la main. Pour un ReadOnlySpan<int> de constantes, le compilateur stocke les données dans un champ RVA en lecture seule et construit le span avec RuntimeHelpers.CreateSpan<int> — sans allocation sur le tas. Pour un Span<T> d’éléments non constants, il synthétise un type tampon décoré de [InlineArray(N)], l’alloue sur la pile, écrit chaque élément et prend un span dessus :

.locals ( valuetype '<>y__InlineArray3`1'<int32> buffer )
// zéro-init du tampon, puis pour chaque élément :
ldloca.s   buffer
call       !0& InlineArrayElementRef<...>(ref buffer, index)
stind.i4                                   // buffer[i] = value
// puis :
ldloca.s   buffer
call       Span<int> InlineArrayAsSpan<...>(ref buffer, 3)

Le type synthétisé <>y__InlineArrayN<T> porte un nom imprononçable et l’attribut [InlineArray], qui ensemble sont une empreinte fiable. Un décompilateur au fait de C# 12 reconnaît le motif et le replie en [...] ; un qui ne l’est pas fera remonter le struct inline-array et l’appel de span tels quels — correct, mais pas joli.

Cible [CollectionBuilder]. Des types comme ImmutableArray<T> annoncent une fabrique avec [CollectionBuilder(typeof(ImmutableArray), "Create")]. Le compilateur matérialise un ReadOnlySpan<T> des éléments (avec la même technique d’inline-array ou de RVA que ci-dessus) et le passe au constructeur :

// ... construire ReadOnlySpan<int> de {1,2,3} comme ci-dessus ...
call       valuetype ImmutableArray`1<int32> ImmutableArray::Create<int32>(ReadOnlySpan`1<int32>)

L’appel à la méthode constructrice désignée, alimenté par un span que la méthode n’a jamais vu construire dans le source, est l’empreinte. Un décompilateur qui connaît la convention reconstruit ImmutableArray<int> x = [1, 2, 3]; ; sinon il montre l’appel explicite ImmutableArray.Create(span).

[1, 2, 3]typé ciblela cible décideint[]newarr / InitializeArrayList<T>new List + Add, Add, …Span<T>inline array sur pileImmutableArray<T>appel [CollectionBuilder]formes d'ILpar ciblereplier[1, 2, 3]reconnue

Spreads : d’abord la longueur, puis les copies

Un élément spread (..) à l’intérieur d’une expression de collection aplatit une autre séquence dans celle-ci. L’abaissement se scinde selon que le nombre de l’opérande est bon marché à apprendre.

Pour int[] tous = [.. premier, .. second]; où les deux opérandes sont des tableaux, le compilateur additionne les longueurs, alloue la destination une seule fois et copie chaque opérande avec un curseur d’index — sans liste intermédiaire :

ldloc premier
ldlen                        // premier.Length
ldloc second
ldlen                        // second.Length
add                          // total
newarr    int32              // allouer la destination une seule fois
// premier.CopyTo(dest, 0); second.CopyTo(dest, premier.Length);

La même forme s’applique à tout opérande exposant un Length/Count, avec CopyTo ou Array.Copy déplaçant le gros. Quand un opérande est un IEnumerable<T> nu dont le nombre est inconnu, le compilateur ne peut pas prédimensionner, il se rabat donc sur l’itération de cet opérande avec un foreach et l’ajout à un constructeur qui grandit. Un décompilateur reconstruit [.. a, .. b] quand il reconnaît le squelette additionner-allouer-copier ; quand l’itération de repli intervient, ou que les copies ont été réordonnées par optimisation, il peut à la place montrer l’arithmétique de longueurs explicite et les appels CopyTo, qui est la lecture honnête de cet IL.

Constructeurs primaires : un constructeur, et des champs de capture seulement si nécessaire

Un constructeur primaire déplace les paramètres vers la déclaration du type :

public class Logger(ILog log, string prefix)
{
    public void Info(string m) => log.Write(prefix + m);
}

L’abaissement est délibérément minimal. Le compilateur émet un constructeur d’instance ordinaire prenant (ILog log, string prefix). Ensuite — et c’est la partie subtile — il synthétise un champ privé uniquement pour les paramètres qu’un membre utilise réellement hors du constructeur et des initialiseurs de champ. Ici, log comme prefix sont lus dans Info, donc les deux sont capturés et obtiennent des champs de stockage aux noms imprononçables, générés par le compilateur, de la forme <paramètre>P :

.field private initonly class ILog '<log>P'
.field private initonly string '<prefix>P'
.method ... .ctor(class ILog log, string prefix)
  ldarg.0
  call   instance void object::.ctor()
  ldarg.0  ldarg.1  stfld  ILog Logger::'<log>P'       // capture log
  ldarg.0  ldarg.2  stfld  string Logger::'<prefix>P'  // capture prefix
  ret

Info lit alors this.<log>P et this.<prefix>P. Si un paramètre n’était utilisé que pour initialiser un champ ou une propriété — public string Prefix = prefix; — il n’obtiendrait pas de champ de capture ; le constructeur le lirait une fois pour poser Prefix puis l’abandonnerait. Les paramètres inutilisés ne génèrent rien du tout. Et un appel de base, class Audited(int id) : Base(id), passe simplement l’argument dans le constructeur synthétisé.

Le contraste avec les records est tout l’enjeu. Le constructeur primaire d’un record génère des propriétés publiques init-only, une égalité par valeur, GetHashCode, ToString/PrintMembers, une méthode de clonage et Deconstruct — une empreinte riche et reconnaissable. Un constructeur primaire de classe ne génère rien de tout cela. Il produit un constructeur et, au plus, quelques champs de capture privés. Ce minimalisme est exactement pourquoi il est difficile à récupérer : un constructeur qui affecte des paramètres à des champs privés readonly est précisément ce que l’on écrit à la main depuis des décennies.

Un décompilateur reconstruit donc class Logger(ILog log, string prefix) uniquement quand il reconnaît la convention de nommage de champ de capture <paramètre>P et l’affectation un-à-un paramètre-vers-champ du constructeur. Quand c’est le cas, la syntaxe du constructeur primaire revient proprement. Quand ce n’est pas le cas — ou quand les champs capturés ont été renommés par obfuscation, ou que vous avez réellement écrit le constructeur explicite — il montre l’équivalent class Logger { private readonly ILog log; … public Logger(ILog log, string prefix) { … } }. Les deux formes compilent vers le même IL, donc le rendu explicite n’est pas un échec ; c’est la lecture fidèle quand l’unique marqueur distinctif du sucre est absent.

Ce qu’il faut retenir de la vue décompilée

Les deux fonctionnalités renforcent la même leçon sur la lecture du .NET compilé : la syntaxe de surface est un choix au-dessus de l’IL, pas une propriété de celui-ci. Une expression de collection est l’un des quatre abaissements qu’exigeait sa cible, et seuls certains portent une empreinte assez forte pour reconstruire les crochets ; vers List<T> elle est littéralement indiscernable d’un initialiseur de collection. Un constructeur primaire de classe est un constructeur plus des champs de capture nommés <x>P, sans rien de la machinerie de record qui rend les records faciles à repérer. Un bon décompilateur replie ce qu’il peut prouver et montre le code abaissé là où il ne le peut pas — et la vue IL est là où vous confirmez ce qui s’est passé. Ouvrez l’assembly dans Glass.NET, basculez une méthode en IL et regardez [1, 2, 3] devenir un newarr ou un tampon inline-array. Pour le flux complet, voyez comment décompiler une DLL .NET en C#.

Essayez Nebula.NET

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