Decompilar foreach: el enumerador, el try/finally oculto y cuándo es en realidad un bucle for
No hay foreach en IL. El compilador de C# lo rebaja a una de tres formas completamente distintas según lo que iteres: una llamada a GetEnumerator con un bucle while y un try/finally oculto que libera el enumerador, una versión con enumerador struct sin ningún dispose, o, sobre un array o un span, un simple bucle for contado por índice sin enumerador a la vista. Un decompilador solo ve el código rebajado y tiene que reconocer qué patrón está mirando para imprimir de vuelta foreach en lugar de la maquinaria. Este artículo lee el IL real de cada caso, muestra por qué el Dispose vive en un finally que el código fuente nunca escribió, por qué el enumerador struct se lo salta, y cómo Glass.NET empareja la forma — el trío GetEnumerator/MoveNext/get_Current, el try/finally, la comprobación de IDisposable — para recuperar el bucle que realmente escribiste.
foreach es una de las primeras cosas que aprende un programador de C# y una de las más elaboradas que hace el compilador. No hay un opcode foreach, ni una instrucción de bucle, ni nada en el IL que diga «esto era un foreach». Lo que hay depende por completo de lo que iteraste: itera una interfaz y obtienes un enumerador y un try/finally oculto; itera una List<T> y obtienes un enumerador struct sin boxing; itera un array y obtienes un simple bucle for contado sin enumerador en absoluto. Tres bucles fuente que parecen idénticos se compilan en tres máquinas distintas.
Un decompilador solo ve la máquina. Su trabajo es reconocer cuál de las tres está mirando e imprimir el único foreach que realmente escribiste — y saber cuándo no puede estar seguro y debe mostrar la maquinaria en su lugar. Este artículo lee el IL real de cada caso y muestra cómo Glass.NET empareja la forma de vuelta con un bucle.
El caso general: un enumerador y un finally que nunca escribiste
Empieza por la forma de manual — iterar un IEnumerable<T> que solo conoces por su interfaz:
int Sum(IEnumerable<int> items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
Tú escribiste un bucle con un cuerpo. El compilador escribió bastante más. Conceptualmente rebajó el foreach a esto:
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;
El try/finally y el Dispose no están en tu código fuente. Son el rebajado del contrato de limpieza de foreach: el lenguaje garantiza que el enumerador se libera cuando el bucle termina por cualquier motivo — finalización normal, un break, un return o una excepción lanzada dentro del cuerpo — y la única construcción que proporciona limpieza en todos esos casos es un finally. Así que el compilador fabrica uno. El IL hace explícita la maquinaria:
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
Cada elemento de la huella del foreach es visible aquí, y estas son las características en las que se fija un decompilador: GetEnumerator() alimentando una variable local; un bucle cuya condición es una llamada a MoveNext(); un cuerpo que empieza leyendo get_Current hacia la variable de iteración; y una región .try cuyo finally correspondiente no hace más que comprobar nulo y llamar a Dispose() sobre la misma variable local de enumerador. Cuando esas cuatro cosas coinciden sobre una misma variable local, la estructura es un foreach con alta confianza.
Un decompilador que no reconociera el patrón imprimiría exactamente el código rebajado de arriba: correcto, compilable y mucho más ruidoso que el bucle de una línea que escribiste. La recuperación es lo que convierte una decompilación fiel-pero-fea en una legible.
El caso del enumerador struct: misma forma, sin boxing
Ahora itera una List<int> concreta en lugar de la interfaz:
int Sum(List<int> items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
El código fuente es idéntico. El IL no. List<T>.GetEnumerator() no devuelve IEnumerator<T>: devuelve un struct, List<T>.Enumerator, y foreach usa duck typing, así que el compilador se vincula a ese método concreto antes de mirar siquiera la interfaz. La variable local de enumerador es ahora un tipo valor, y las llamadas a MoveNext/get_Current/Dispose se hacen directamente sobre ella:
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()
}
Dos cosas cambiaron y ambas importan a un decompilador. Primero, las llamadas se hacen sobre ldloca.s e — la dirección de una variable local de tipo valor — usando call y constrained. callvirt, sin asignación ni boxing. Ese es el diseño de rendimiento deliberado detrás de List<T>: el bucle habitual no asigna nada. Un decompilador debe conservar que se trata de un enumerador de tipo valor en lugar de inventar un IEnumerator<int> que no está ahí. Segundo, el finally sigue presente aquí porque List<T>.Enumerator implementa IDisposable — pero cuando se sabe que el enumerador struct no necesita liberarse, el compilador omite el try/finally por completo, y el bucle es solo el par MoveNext/Current sin región de limpieza. Un decompilador tiene que tratar el finally como opcional: su ausencia no significa «no es un foreach», significa «un enumerador que no necesitaba liberarse».
El caso del array: ningún enumerador en absoluto
La tercera forma es la que sorprende a la gente, porque el enumerador desaparece por completo. Itera un array:
int Sum(int[] items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
El compilador sabe que el operando es un array, y los arrays tienen una iteración especial y más barata: no llama a GetEnumerator en absoluto. Emite un simple bucle for contado por índice — una instantánea de la longitud, una variable de índice, una condición limitada por el tamaño y ldelem para leer cada elemento:
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)
Este IL es indistinguible de un for (int i = 0; i < items.Length; i++) escrito a mano que lee items[i]. Genuinamente no hay metadato que registre si escribiste foreach o for; el compilador rebajó ambos a las mismas instrucciones. Así que un decompilador se enfrenta a una elección real, y distintos decompiladores la responden de forma distinta. La heurística que aplica Glass.NET: si la variable de índice se usa solo para indexar el array y para incrementarse — nunca se lee por su propio valor dentro del cuerpo — y el límite es exactamente el Length del array, el bucle fue casi con seguridad un foreach, y representarlo como tal es a la vez más fiel a la intención y más legible. Si el índice se usa en el cuerpo (digamos, items[i] + i), tuvo que ser un for, porque foreach no te da ningún índice. La misma lógica se extiende a Span<T> y ReadOnlySpan<T>, que el compilador también rebaja a un bucle por índice sobre el Length del span.
Cómo decide Glass.NET
Juntando las tres formas, la recuperación de foreach es emparejamiento de patrones con un umbral de confianza en lugar de una inversa garantizada. Glass.NET ejecuta primero la estructuración general del flujo de control — bloques básicos, el bucle, las regiones try/finally — y después pregunta si el bucle coincide con una de las huellas de foreach:
- Forma de enumerador. ¿Hay una llamada a
GetEnumerator()cuyo resultado alimenta una variable local que solo usanMoveNext,get_Currenty (si está presente)Dispose? ¿Es la condición del bucle eseMoveNext, y empieza el cuerpo leyendoget_Currenthacia la variable de iteración? ¿Cualquierfinallyenvolvente no hace más que liberar ese enumerador? Si es así, se pliega todo enforeachy se eliminan la variable local sintética, eltry/finallyy elDispose. - Forma de array/span. ¿Es el bucle un índice contado de 0 a
Lengthdonde el índice se usa solo para indexar la colección y avanzar? Si es así, se prefiereforeach; si el índice se escapa al cuerpo, se mantiene comofor.
Cuando todas las piezas encajan, la salida es el bucle de una línea que escribiste. Cuando falta una pieza — la variable local de enumerador se reutiliza para otra cosa, el finally hace más que liberar, el índice se lee en el cuerpo — Glass.NET se niega deliberadamente a plegar y muestra el while literal con su try/finally, o el for explícito. Ese es el modo de fallo correcto para un decompilador: un bucle ligeramente más ruidoso pero incuestionablemente correcto es mejor que un foreach bonito que tergiversa en silencio lo que hace el código.
La conclusión
foreach es una buena lente para ver qué es realmente la decompilación. El compilador tomó una construcción y la rebajó de tres maneras distintas según el operando, borrando el hecho de que alguna vez fue un foreach; el try/finally y el Dispose que el bucle parece contener nunca estuvieron en tu código fuente, y sobre un array el enumerador que el bucle parece usar nunca existió. Leer el código de vuelta significa reconocer cada máquina por su forma y tener la disciplina de plegarla solo cuando la forma es inequívoca — imprimiendo el bucle que escribiste cuando la evidencia lo respalda, y la maquinaria cuando no.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.