foreach dekompilieren: der Enumerator, das versteckte try/finally und wann es in Wirklichkeit eine for-Schleife ist
Es gibt kein foreach in IL. Der C#-Compiler senkt es in eine von drei völlig verschiedenen Formen ab, je nachdem, worüber Sie iterieren — ein Aufruf von GetEnumerator mit einer while-Schleife und einem versteckten try/finally, das den Enumerator freigibt, eine Struct-Enumerator-Variante ganz ohne Dispose oder, über ein Array oder einen Span, eine schlichte indexgezählte for-Schleife ohne jeden Enumerator. Ein Decompiler sieht nur den abgesenkten Code und muss erkennen, welches Muster er vor sich hat, um foreach statt der Maschinerie auszugeben. Dieser Beitrag liest die echte IL für jeden Fall, zeigt, warum das Dispose in einem finally steht, das der Quelltext nie geschrieben hat, warum der Struct-Enumerator es überspringt und wie Glass.NET die Form — das Trio GetEnumerator/MoveNext/get_Current, das try/finally, die IDisposable-Prüfung — zuordnet, um die Schleife wiederherzustellen, die Sie tatsächlich geschrieben haben.
foreach ist eines der ersten Dinge, die ein C#-Programmierer lernt, und eines der aufwendigeren Dinge, die der Compiler tut. Es gibt keinen foreach-Opcode, keine Schleifeninstruktion, nichts in der IL, das sagt „das war ein foreach“. Was es gibt, hängt ganz davon ab, worüber Sie iteriert haben: Iterieren Sie über ein Interface, bekommen Sie einen Enumerator und ein verstecktes try/finally; iterieren Sie über eine List<T>, bekommen Sie einen Struct-Enumerator ohne Boxing; iterieren Sie über ein Array, bekommen Sie eine schlichte gezählte for-Schleife ganz ohne Enumerator. Drei Quelltextschleifen, die identisch aussehen, kompilieren zu drei verschiedenen Maschinen.
Ein Decompiler sieht nur die Maschine. Seine Aufgabe ist es, zu erkennen, welche der drei er vor sich hat, und das eine foreach auszugeben, das Sie tatsächlich geschrieben haben — und zu wissen, wann er sich nicht sicher sein kann und stattdessen die Maschinerie zeigen sollte. Dieser Beitrag liest die echte IL für jeden Fall und zeigt, wie Glass.NET die Form zur Schleife zurückführt.
Der allgemeine Fall: ein Enumerator und ein finally, das Sie nie geschrieben haben
Beginnen wir mit der Lehrbuchform — dem Iterieren über ein IEnumerable<T>, das Sie nur über sein Interface kennen:
int Sum(IEnumerable<int> items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
Sie haben eine Schleife mit einem Rumpf geschrieben. Der Compiler hat erheblich mehr geschrieben. Konzeptionell hat er das foreach hierzu abgesenkt:
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;
Das try/finally und das Dispose stehen nicht in Ihrem Quelltext. Sie sind die Absenkung des Aufräumvertrags von foreach: Die Sprache garantiert, dass der Enumerator freigegeben wird, wenn die Schleife aus irgendeinem Grund endet — normale Beendigung, ein break, ein return oder eine im Rumpf geworfene Ausnahme —, und das einzige Konstrukt, das Aufräumen über all diese Fälle hinweg leistet, ist ein finally. Also fertigt der Compiler eines an. Die IL macht die Maschinerie explizit:
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
Jedes Element des foreach-Fingerabdrucks ist hier sichtbar, und das sind die Merkmale, auf die ein Decompiler achtet: GetEnumerator(), das eine Lokale füllt; eine Schleife, deren Bedingung ein MoveNext()-Aufruf ist; ein Rumpf, der damit beginnt, get_Current in die Iterationsvariable zu lesen; und eine .try-Region, deren zugehöriges finally nichts tut, außer dieselbe Enumerator-Lokale auf null zu prüfen und Dispose() aufzurufen. Wenn diese vier Dinge auf einer Lokalen zusammenpassen, ist die Struktur mit hoher Konfidenz ein foreach.
Ein Decompiler, der das Muster nicht erkennen würde, würde genau den abgesenkten Code von oben ausgeben — korrekt, kompilierbar und weit lauter als die Einzeiler-Schleife, die Sie geschrieben haben. Die Wiederherstellung ist das, was eine getreue, aber hässliche Dekompilierung in eine lesbare verwandelt.
Der Struct-Enumerator-Fall: dieselbe Form, kein Boxing
Iterieren Sie nun über eine konkrete List<int> statt über das Interface:
int Sum(List<int> items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
Der Quelltext ist identisch. Die IL nicht. List<T>.GetEnumerator() gibt kein IEnumerator<T> zurück — es gibt ein Struct zurück, List<T>.Enumerator, und foreach ist duck-typed, sodass der Compiler an diese konkrete Methode bindet, bevor er überhaupt auf das Interface schaut. Die Enumerator-Lokale ist nun ein Werttyp, und die Aufrufe von MoveNext/get_Current/Dispose erfolgen direkt darauf:
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()
}
Zwei Dinge haben sich geändert, und beide sind für einen Decompiler relevant. Erstens erfolgen die Aufrufe auf ldloca.s e — der Adresse einer Werttyp-Lokalen — per call und constrained. callvirt, ohne Allokation und ohne Boxing. Das ist das bewusste Performance-Design hinter List<T>: Die gewöhnliche Schleife allokiert nichts. Ein Decompiler muss bewahren, dass es sich um einen Werttyp-Enumerator handelt, statt ein IEnumerator<int> zu erfinden, das nicht da ist. Zweitens ist das finally hier noch vorhanden, weil List<T>.Enumerator IDisposable implementiert — aber wenn der Struct-Enumerator bekanntermaßen nicht freigegeben werden muss, lässt der Compiler das try/finally ganz weg, und die Schleife ist nur das MoveNext/Current-Paar ohne Aufräumregion. Ein Decompiler muss das finally als optional behandeln: Sein Fehlen bedeutet nicht „kein foreach“, sondern „ein Enumerator, der kein Freigeben brauchte“.
Der Array-Fall: überhaupt kein Enumerator
Die dritte Form ist die, die Leute überrascht, denn der Enumerator verschwindet vollständig. Iterieren Sie über ein Array:
int Sum(int[] items)
{
int total = 0;
foreach (int x in items)
total += x;
return total;
}
Der Compiler weiß, dass der Operand ein Array ist, und Arrays haben eine spezielle, günstigere Iteration: Er ruft GetEnumerator überhaupt nicht auf. Er emittiert eine schlichte indexgezählte for-Schleife — einen Längen-Snapshot, eine Indexvariable, eine grenzenbeschränkte Bedingung und ldelem, um jedes Element zu lesen:
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)
Diese IL ist ununterscheidbar von einem handgeschriebenen for (int i = 0; i < items.Length; i++), das items[i] liest. Es gibt wirklich keine Metadaten, die festhalten, ob Sie foreach oder for geschrieben haben; der Compiler hat beide zu denselben Instruktionen abgesenkt. Ein Decompiler steht also vor einer echten Wahl, und verschiedene Decompiler beantworten sie unterschiedlich. Die Heuristik, die Glass.NET anwendet: Wenn die Indexvariable nur zum Indizieren des Arrays und zum Inkrementieren verwendet wird — und innerhalb des Rumpfs nie um ihres eigenen Werts willen gelesen wird — und die Grenze exakt die Length des Arrays ist, war die Schleife mit großer Wahrscheinlichkeit ein foreach, und sie als solches darzustellen ist sowohl der Absicht treuer als auch lesbarer. Wird der Index im Rumpf verwendet (etwa items[i] + i), muss es ein for gewesen sein, denn foreach gibt Ihnen keinen Index. Dieselbe Logik erstreckt sich auf Span<T> und ReadOnlySpan<T>, die der Compiler ebenfalls zu einer Indexschleife über die Length des Spans absenkt.
Wie Glass.NET entscheidet
Nimmt man die drei Formen zusammen, ist die foreach-Wiederherstellung Mustererkennung mit einer Konfidenzschwelle und keine garantierte Umkehrung. Glass.NET führt zuerst die allgemeine Kontrollfluss-Strukturierung aus — Basisblöcke, die Schleife, die try/finally-Regionen — und fragt dann, ob die Schleife einem der foreach-Fingerabdrücke entspricht:
- Enumerator-Form. Gibt es einen
GetEnumerator()-Aufruf, dessen Ergebnis eine Lokale füllt, die nur vonMoveNext,get_Currentund (falls vorhanden)Disposeverwendet wird? Ist die Schleifenbedingung diesesMoveNext, und beginnt der Rumpf damit,get_Currentin die Iterationsvariable zu lesen? Tut ein umgebendesfinallynichts, außer diesen Enumerator freizugeben? Wenn ja, wird all das inforeachgefaltet, und die synthetische Lokale, dastry/finallyund dasDisposeentfallen. - Array/Span-Form. Ist die Schleife ein gezählter Index von 0 bis
Length, bei dem der Index nur zum Indizieren der Sammlung und zum Weiterzählen verwendet wird? Wenn ja, wirdforeachbevorzugt; entkommt der Index in den Rumpf, bleibt es einfor.
Wenn jedes Teil zusammenpasst, ist die Ausgabe die Einzeiler-Schleife, die Sie geschrieben haben. Fehlt ein Teil — die Enumerator-Lokale wird für etwas anderes wiederverwendet, das finally tut mehr als freigeben, der Index wird im Rumpf gelesen —, verzichtet Glass.NET bewusst auf das Falten und zeigt das wörtliche while mit seinem try/finally oder das explizite for. Das ist der richtige Fehlermodus für einen Decompiler: Eine etwas lautere Schleife, die fraglos korrekt ist, schlägt ein hübsches foreach, das stillschweigend falsch darstellt, was der Code tut.
Das Fazit
foreach ist eine gute Linse darauf, was Dekompilierung tatsächlich ist. Der Compiler hat ein Konstrukt genommen und es je nach Operand auf drei verschiedene Arten abgesenkt und dabei die Tatsache ausgelöscht, dass es jemals ein foreach war; das try/finally und das Dispose, die die Schleife zu enthalten scheint, standen nie in Ihrem Quelltext, und über ein Array hat der Enumerator, den die Schleife zu verwenden scheint, nie existiert. Den Code zurückzulesen bedeutet, jede Maschine an ihrer Form zu erkennen und die Disziplin zu haben, sie nur dann zu falten, wenn die Form eindeutig ist — die Schleife auszugeben, die Sie geschrieben haben, wenn die Belege es stützen, und die Maschinerie, wenn nicht.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.