Décompiler les collections params : où est passé mon tableau ?
Pendant vingt ans, un paramètre params signifiait une seule chose en IL : l'appelant construisait un newarr et le remplissait. C# 13 a généralisé params à n'importe quel type de collection, et le cas vedette — params ReadOnlySpan<T> — n'alloue strictement rien sur le tas. L'appelant empile les arguments dans un tampon inline-array généré par le compilateur et enveloppe un span autour ; params List<T> passe quant à lui par un collection builder. L'indice de décompilation se trouve entièrement sur le site d'appel, pas dans la signature. Voici ce que chaque forme émet, comment un décompilateur replie le tampon en un appel ordinaire, et pourquoi un params ReadOnlySpan<T> qui affiche un newarr est un chemin de repli froid qui mérite attention.
params est la plus ancienne commodité de C# : écrivez string.Format("{0}+{1}", a, b) et le compilateur construit discrètement le object[] que Format prend réellement. Pendant vingt ans, ce désucrage n’a eu qu’une seule forme — un newarr, une série de stelem et l’appel — et tous les décompilateurs ont appris à la replier en arguments libres.
C# 13 a généralisé params à n’importe quel type de collection, et le membre intéressant de la nouvelle famille, params ReadOnlySpan<T>, n’alloue rien sur le tas. Les arguments vont dans un tampon alloué sur la pile et un span est enveloppé autour. La signature de la méthode change à peine ; toute la différence se trouve sur le site d’appel. Un décompilateur qui ne connaît encore que la forme newarr vous montrera un type de tampon généré par le compilateur que vous ne pouvez pas nommer et un constructeur de span là où le source avait un appel ordinaire.
La signature bouge à peine ; le site d’appel bouge beaucoup
Prenons une méthode de journalisation migrée de la forme classique vers la forme span :
// Classic — unchanged since C# 1
public static void LogOld(params object[] args) { /* ... */ }
// C# 13 — one word of the type changes
public static void LogNew(params ReadOnlySpan<object> args) { /* ... */ }
Les deux s’appellent de manière identique : LogNew(id, name, DateTime.Now). Mais l’IL que l’appelant émet est complètement différent. La forme classique est le tableau sur le tas bien connu :
// LogOld(id, name, now)
ldc.i4.3
newarr [System.Runtime]System.Object // allocate object[3] on the heap
dup
ldc.i4.0
ldarg.0
stelem.ref // args[0] = id
dup
ldc.i4.1
ldarg.1
stelem.ref // args[1] = name
// ... args[2] = now ...
call void Log::LogOld(object[])
La forme span n’alloue aucun tableau. Le compilateur émet un type inline-array généré par le compilateur — une struct portant [InlineArray(3)], le même mécanisme que C# utilise pour les inline arrays et stackalloc — comme locale dans le cadre de l’appelant, stocke chaque argument dedans via les intrinsèques d’inline-array, construit un ReadOnlySpan<object> par-dessus le tampon et le passe :
// LogNew(id, name, now)
.locals init ([0] valuetype '<>y__InlineArray3`1'<object> buffer)
ldloca.s buffer
initobj valuetype '<>y__InlineArray3`1'<object>
// element 0
ldloca.s buffer
ldc.i4.0
call !0& InlineArrayElementRef<'<>y__InlineArray3`1'<object>,object>(ref, int)
ldarg.0
stind.ref // buffer[0] = id
// ... buffer[1] = name, buffer[2] = now via the same intrinsic ...
ldloca.s buffer
ldc.i4.3
call ReadOnlySpan<object> InlineArrayAsReadOnlySpan<...>(ref, int)
call void Log::LogNew(valuetype ReadOnlySpan`1<object>)
Pas de newarr, pas d’objet sur le tas, pas de pression sur le GC. Le tampon est un type valeur sur la pile ; il meurt avec le cadre. Toute la différence est dans l’appelant — l’appelé a simplement reçu un span.
Les trois formes qu’un décompilateur doit connaître
params possède désormais une petite famille d’abaissements (lowerings), choisis selon le type de collection du paramètre. Un décompilateur lit le site d’appel pour les distinguer et doit toutes les replier en arguments libres.
params T[] — le chemin classique, inchangé. Le type du paramètre est un tableau, donc l’appelant émet newarr + stelem + appel. Un décompilateur le replie exactement comme il l’a toujours fait. Rien de nouveau ici, et c’est bien le but : l’ancien code conserve son ancien IL.
params ReadOnlySpan<T> / params Span<T> — le chemin sans allocation. Le tampon <>y__InlineArrayN, les stockages d’éléments via InlineArrayElementRef, l’appel à InlineArrayAsReadOnlySpan, puis l’appel à la cible. La règle de reconnaissance est : une locale inline-array générée par le compilateur, remplie élément par élément et enveloppée dans un span qui alimente immédiatement un appel params, ce sont des arguments libres. Rendez-le comme LogNew(id, name, now), pas comme un tampon visible et un constructeur de span — le type <>y__InlineArray3 ne peut pas être nommé en source C#, donc l’émettre littéralement produit du code qui ne recompilera pas.
params List<T> / params IEnumerable<T> — le chemin du collection builder. Pour une collection qui n’est pas un span, le compilateur réutilise la machinerie des expressions de collection : il construit la collection (une List<T> via son chemin d’ajout, ou la méthode [CollectionBuilder] d’un type) et passe la collection terminée. Le décompilateur doit reconnaître la séquence du builder et la replier elle aussi en arguments libres.
Les trois se réduisent au même source — foo(a, b, c) —, donc le travail du décompilateur consiste à effacer le mécanisme et à retrouver l’appel.
L’indice : un span alimenté par newarr
Voici le cas subtil qui mérite d’être repéré. Même un paramètre params ReadOnlySpan<T> n’obtient pas toujours le tampon sur la pile. Le compilateur n’alloue sur la pile que lorsqu’il peut prouver que le span ne s’échappera pas ; sur les chemins où il ne le peut pas, il se rabat sur un tableau du tas et enveloppe le span autour de celui-ci. Vous verrez donc parfois :
ldc.i4.3
newarr [System.Runtime]System.Object // heap array, not an inline buffer
dup / stelem.ref ... // fill it
newobj instance void ReadOnlySpan<object>::.ctor(!0[]) // span over the array
call void Log::LogNew(valuetype ReadOnlySpan`1<object>)
Un newarr alimentant un constructeur de ReadOnlySpan, plutôt qu’un tampon <>y__InlineArray, est l’empreinte du chemin de repli. C’est toujours un appel params correct et il se replie toujours en LogNew(id, name, now) dans le source décompilé — mais il vous dit que cet appel particulier a bel et bien alloué, ce qui est exactement ce que la forme span avait été choisie pour éviter. Quand vous lisez une sortie décompilée pour comprendre un profil d’allocation, cette distinction est toute l’histoire : même ligne de source, coût différent, et seul l’IL montre lequel des deux vous avez obtenu.
Ce qu’un bon décompilateur émet
Mettez les formes côte à côte et l’obligation est claire. Pour l’appel sans allocation, un décompilateur qui s’arrête au métal affiche :
// Wrong: literal, and won't recompile — <>y__InlineArray3 isn't nameable
<>y__InlineArray3<object> buffer = default;
InlineArrayElementRef<...>(ref buffer, 0) = id;
// ...
LogNew(InlineArrayAsReadOnlySpan<...>(ref buffer, 3));
tandis que retrouver l’intention de l’auteur donne :
// Right: the call the author actually wrote
LogNew(id, name, DateTime.Now);
Glass.NET replie les trois formes de params — le tableau classique, le span sur inline-array et les formes à collection builder — en un appel avec des arguments libres, et garde explicite un argument span ou liste quand le source en a réellement passé un plutôt que des valeurs libres. La fonctionnalité a déplacé le travail intéressant de la signature de la méthode vers le site d’appel, c’est donc là qu’un décompilateur doit regarder. Retrouvez l’appel, supprimez le tampon et notez l’indice d’allocation quand vous en avez besoin.
Voir inline arrays et stackalloc pour le mécanisme de tampon que ceci réutilise, et expressions de collection pour le chemin du builder que partagent les formes sans span.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.