Décompiler foreach : l'énumérateur, le try/finally caché, et quand c'est en réalité une boucle for
Il n'y a pas de foreach en IL. Le compilateur C# l'abaisse vers l'une de trois formes complètement différentes selon ce que vous itérez — un appel à GetEnumerator avec une boucle while et un try/finally caché qui dispose l'énumérateur, une version à énumérateur struct sans aucun dispose, ou, sur un tableau ou un span, une simple boucle for à index sans énumérateur en vue. Un décompilateur ne voit que le code abaissé et doit reconnaître quel motif il regarde pour réimprimer foreach au lieu de la machinerie. Cet article lit l'IL réel de chaque cas, montre pourquoi le Dispose vit dans un finally que la source n'a jamais écrit, pourquoi l'énumérateur struct le saute, et comment Glass.NET fait correspondre la forme — le trio GetEnumerator/MoveNext/get_Current, le try/finally, le test IDisposable — pour retrouver la boucle que vous avez réellement écrite.
foreach est l’une des premières choses qu’un programmeur C# apprend et l’une des choses les plus élaborées que fait le compilateur. Il n’y a pas d’opcode foreach, pas d’instruction de boucle, rien dans l’IL qui dise « c’était un foreach ». Ce qu’il y a dépend entièrement de ce que vous avez itéré : itérez une interface et vous obtenez un énumérateur et un try/finally caché ; itérez une List<T> et vous obtenez un énumérateur struct sans boxing ; itérez un tableau et vous obtenez une simple boucle for comptée sans aucun énumérateur. Trois boucles source identiques compilent en trois machines différentes.
Un décompilateur ne voit que la machine. Son travail est de reconnaître laquelle des trois il regarde et d’imprimer l’unique foreach que vous avez réellement écrit — et de savoir quand il ne peut pas en être sûr, et doit montrer la machinerie à la place. Cet article lit l’IL réel de chaque cas et montre comment Glass.NET fait correspondre la forme à une boucle.
Le cas général : un énumérateur et un finally que vous n’avez jamais écrit
Commencez par la forme de manuel — itérer un IEnumerable<T> que vous ne connaissez que par son interface :
int Sum(IEnumerable<int> items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
Vous avez écrit une boucle avec un corps. Le compilateur a écrit considérablement plus. Conceptuellement, il a abaissé le foreach en ceci :
int total = 0;
IEnumerator<int> e = items.GetEnumerator();
try
{
while (e.MoveNext())
{
int x = e.Current;
total += x;
}
}
finally
{
if (e != null) e.Dispose();
}
return total;
Le try/finally et le Dispose ne sont pas dans votre source. Ce sont l’abaissement du contrat de nettoyage de foreach : le langage garantit que l’énumérateur est disposé quand la boucle se termine pour n’importe quelle raison — fin normale, un break, un return, ou une exception levée dans le corps — et la seule construction qui assure un nettoyage dans tous ces cas est un finally. Le compilateur en fabrique donc un. L’IL rend la machinerie explicite :
IL_0000: ldc.i4.0
IL_0001: stloc.0 // total = 0
IL_0002: ldarg.0
IL_0003: callvirt instance class IEnumerator`1<!0> IEnumerable`1<int32>::GetEnumerator()
IL_0008: stloc.1 // e = items.GetEnumerator()
.try
{
IL_0009: br.s IL_0018 // jump to the MoveNext test (test-at-bottom)
IL_000b: ldloc.1
IL_000c: callvirt instance !0 IEnumerator`1<int32>::get_Current()
IL_0011: stloc.2 // x = e.Current
IL_0012: ldloc.0
IL_0013: ldloc.2
IL_0014: add
IL_0015: stloc.0 // total += x
IL_0018: ldloc.1
IL_0019: callvirt instance bool IEnumerator::MoveNext()
IL_001e: brtrue.s IL_000b // loop while MoveNext() is true
IL_0020: leave.s IL_0032 // normal exit runs the finally
}
finally
{
IL_0022: ldloc.1
IL_0023: brfalse.s IL_0031 // if (e == null) skip
IL_0025: ldloc.1
IL_0026: callvirt instance void IDisposable::Dispose()
IL_0031: endfinally
}
IL_0032: ldloc.0
IL_0033: ret
Chaque élément de l’empreinte foreach est visible ici, et ce sont les traits sur lesquels un décompilateur s’appuie : GetEnumerator() alimentant une locale ; une boucle dont la condition est un appel à MoveNext() ; un corps qui commence par lire get_Current dans la variable d’itération ; et une région .try dont le finally correspondant ne fait rien d’autre que tester la nullité et Dispose() la même locale d’énumérateur. Quand ces quatre choses s’alignent sur une seule locale, la structure est un foreach avec une confiance élevée.
Un décompilateur qui ne reconnaîtrait pas le motif imprimerait exactement le code abaissé ci-dessus — correct, compilable, et bien plus bruyant que la boucle d’une ligne que vous avez écrite. La récupération est ce qui transforme une décompilation fidèle-mais-laide en une décompilation lisible.
Le cas de l’énumérateur struct : même forme, pas de boxing
Itérez maintenant une List<int> concrète au lieu de l’interface :
int Sum(List<int> items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
La source est identique. L’IL ne l’est pas. List<T>.GetEnumerator() ne renvoie pas IEnumerator<T> — il renvoie un struct, List<T>.Enumerator, et foreach est typé par duck typing, donc le compilateur se lie à cette méthode concrète avant même de regarder l’interface. La locale d’énumérateur est désormais un type valeur, et les appels MoveNext/get_Current/Dispose sont faits directement dessus :
IL_0003: callvirt instance valuetype List`1/Enumerator<!0> List`1<int32>::GetEnumerator()
IL_0008: stloc.1 // e is a List<int>.Enumerator VALUE, not an interface
...
IL_0018: ldloca.s e // address of the struct — no boxing
IL_001a: call instance bool List`1/Enumerator<int32>::MoveNext()
...
finally
{
IL_0025: ldloca.s e
IL_0027: constrained. List`1/Enumerator<int32>
IL_002d: callvirt instance void IDisposable::Dispose()
}
Deux choses ont changé et les deux comptent pour un décompilateur. D’abord, les appels se font sur ldloca.s e — l’adresse d’une locale de type valeur — avec call et constrained. callvirt, sans allocation ni boxing. C’est la conception de performance délibérée derrière List<T> : la boucle courante n’alloue rien. Un décompilateur doit préserver le fait qu’il s’agit d’un énumérateur de type valeur plutôt que d’inventer un IEnumerator<int> qui n’existe pas. Ensuite, le finally est encore présent ici parce que List<T>.Enumerator implémente IDisposable — mais quand l’énumérateur struct est connu pour ne pas avoir besoin de disposal, le compilateur omet entièrement le try/finally, et la boucle n’est plus que la paire MoveNext/Current sans région de nettoyage. Un décompilateur doit traiter le finally comme optionnel : son absence ne signifie pas « pas un foreach », elle signifie « un énumérateur qui n’avait pas besoin d’être disposé ».
Le cas du tableau : aucun énumérateur du tout
La troisième forme est celle qui surprend, parce que l’énumérateur disparaît complètement. Itérez un tableau :
int Sum(int[] items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
Le compilateur sait que l’opérande est un tableau, et les tableaux ont une itération spéciale, moins coûteuse : il n’appelle pas du tout GetEnumerator. Il émet une simple boucle for à index — un instantané de la longueur, une variable d’index, une condition bornée par la longueur, et ldelem pour lire chaque élément :
IL_0002: ldarg.0
IL_0003: stloc.1 // array copy (the thing iterated)
IL_0004: ldc.i4.0
IL_0005: stloc.2 // i = 0
IL_0006: br.s IL_0016
IL_0008: ldloc.1
IL_0009: ldloc.2
IL_000a: ldelem.i4 // x = items[i]
IL_000b: stloc.3
... // total += x
IL_0012: ldloc.2
IL_0013: ldc.i4.1
IL_0014: add
IL_0015: stloc.2 // i++
IL_0016: ldloc.2
IL_0017: ldloc.1
IL_0018: ldlen
IL_0019: conv.i4
IL_001a: blt.s IL_0008 // while (i < items.Length)
Cet IL est indiscernable d’un for (int i = 0; i < items.Length; i++) écrit à la main qui lit items[i]. Il n’existe véritablement aucune métadonnée qui enregistre si vous avez écrit foreach ou for ; le compilateur a abaissé les deux vers les mêmes instructions. Un décompilateur fait donc face à un vrai choix, et différents décompilateurs y répondent différemment. L’heuristique qu’applique Glass.NET : si la variable d’index est utilisée uniquement pour indexer le tableau et s’incrémenter — jamais lue pour sa propre valeur dans le corps — et si la borne est exactement le Length du tableau, la boucle était presque certainement un foreach, et la rendre ainsi est à la fois plus fidèle à l’intention et plus lisible. Si l’index est utilisé dans le corps (disons items[i] + i), c’était forcément un for, parce que foreach ne vous donne aucun index. La même logique s’étend à Span<T> et ReadOnlySpan<T>, que le compilateur abaisse aussi vers une boucle à index sur le Length du span.
Comment Glass.NET décide
En rassemblant les trois formes, la récupération de foreach est une correspondance de motif avec un seuil de confiance plutôt qu’un inverse garanti. Glass.NET exécute d’abord la structuration générale du flot de contrôle — blocs de base, la boucle, les régions try/finally — puis demande si la boucle correspond à l’une des empreintes foreach :
- Forme énumérateur. Y a-t-il un appel à
GetEnumerator()dont le résultat alimente une locale utilisée uniquement parMoveNext,get_Currentet (s’il est présent)Dispose? La condition de boucle est-elle ceMoveNext, et le corps s’ouvre-t-il par la lecture deget_Currentdans la variable d’itération ? Un éventuelfinallyenglobant ne fait-il rien d’autre que disposer cet énumérateur ? Si oui, replier tout cela enforeachet supprimer la locale synthétique, letry/finallyet leDispose. - Forme tableau/span. La boucle est-elle un index compté de 0 à
Lengthoù l’index sert uniquement à indexer la collection et à avancer ? Si oui, préférerforeach; si l’index s’échappe dans le corps, garder unfor.
Quand chaque pièce s’aligne, la sortie est la boucle d’une ligne que vous avez écrite. Quand une pièce manque — la locale d’énumérateur est réutilisée pour autre chose, le finally fait plus que disposer, l’index est lu dans le corps — Glass.NET refuse délibérément de replier et montre le while littéral avec son try/finally, ou le for explicite. C’est le bon mode de défaillance pour un décompilateur : une boucle légèrement plus bruyante mais incontestablement correcte vaut mieux qu’un joli foreach qui déforme discrètement ce que fait le code.
À retenir
foreach est une bonne lentille sur ce qu’est réellement la décompilation. Le compilateur a pris une construction et l’a abaissée de trois façons différentes selon l’opérande, effaçant le fait qu’elle ait jamais été un foreach ; le try/finally et le Dispose que la boucle semble contenir n’ont jamais été dans votre source, et sur un tableau, l’énumérateur que la boucle semble utiliser n’a jamais existé. Relire le code signifie reconnaître chaque machine à sa forme et avoir la discipline de ne replier que lorsque la forme est sans ambiguïté — imprimer la boucle que vous avez écrite quand les preuves le soutiennent, et la machinerie quand ce n’est pas le cas.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.