yield return dekompilieren: wie Iteratorblöcke zu Zustandsmaschinen werden
Eine Iteratormethode mit yield return hat keine direkte IL-Darstellung — der Compiler schreibt sie in eine generierte Klasse um, die IEnumerator implementiert und innerhalb eines MoveNext-switch fortsetzt. Hier steht die Form dieser Zustandsmaschine, wie ein Decompiler sie erkennt und wie yield return und yield break aus dem generierten Code wiedergewonnen werden.
Manche C#-Konstrukte kompilieren zu einer einzigen IL-Instruktion. yield return kompiliert zu einer ganzen Klasse. Es gibt keinen „yield”-Opcode im CLR, also schreibt der Compiler, wenn Sie eine Iteratormethode schreiben, das Ganze in einen generierten Typ um, der IEnumerator<T> implementiert und sich zwischen den Aufrufen merkt, wo er war. Diese Umschreibung zu verstehen erklärt, warum ein Decompiler Ihnen manchmal ein kryptisches <GetItems>d__0 mit einem switch darin zeigt — und wie ein guter es zurück in das yield return verwandelt, das Sie tatsächlich geschrieben haben.
Die Methode, die Sie schreiben, vs. die Methode, die ausgeliefert wird
Hier ist ein gewöhnlicher Iterator:
public IEnumerable<int> Evens(int max)
{
for (int i = 0; i <= max; i++)
if (i % 2 == 0)
yield return i;
}
Was in der Assembly ausgeliefert wird, ist fast nichts davon. Die Methode Evens überlebt nur als Stub, der eine generierte Klasse erzeugt und zurückgibt. Die eigentliche Arbeit wandert in einen compilergenerierten verschachtelten Typ — konventionell <Evens>d__0 genannt — der IEnumerable<int>, IEnumerator<int> und IDisposable implementiert. Das dekompilierte Skelett sieht so aus:
[CompilerGenerated]
private sealed class <Evens>d__0 : IEnumerable<int>, IEnumerator<int>, IDisposable
{
private int <>1__state; // where we are in the method
private int <>2__current; // the value the last yield produced
public int max; // the parameter, hoisted to a field
private int <i>5__1; // the local 'i', hoisted to a field
int IEnumerator<int>.Current => <>2__current;
bool IEnumerator.MoveNext() { /* the rewritten body — see below */ }
// ... Reset/Dispose/GetEnumerator ...
}
Zwei Arten von Feldern tauchen auf. <>1__state und <>2__current sind die Maschinerie: Das Zustandsfeld hält fest, wo fortzusetzen ist, und das current-Feld hält den Wert, den Current zurückgibt. Die anderen — max und <i>5__1 — sind Ihr Parameter und Ihre Locale i, vom Stack auf den Heap hochgezogen. Sie müssen Felder sein, denn eine Locale auf dem Stack verschwände, sobald MoveNext zurückkehrt, und der Iterator braucht i noch beim nächsten Aufruf.
MoveNext: ein switch, der fortsetzt
Der Körper Ihres Iterators wird in MoveNext umgeschrieben, strukturiert als Verteilung über <>1__state. Jedes yield return wird zu drei Schritten — den Wert ablegen, einen Fortsetzungszustand merken, true zurückgeben — und der passende case ist dort, wo der nächste Aufruf zurückspringt:
bool IEnumerator.MoveNext()
{
switch (<>1__state)
{
case 0: // first call: start the method
<>1__state = -1;
<i>5__1 = 0; // i = 0
break;
case 1: // resume point after the yield
<>1__state = -1;
<i>5__1++; // the i++ from the for-loop
break;
default:
return false;
}
while (<i>5__1 <= max)
{
if (<i>5__1 % 2 == 0)
{
<>2__current = <i>5__1; // yield return i → stash value
<>1__state = 1; // remember where to resume
return true; // and hand control back
}
<i>5__1++;
}
return false; // falling off the end = yield break
}
Lesen Sie es als fortsetzbares Programm. Zustand 0 ist der Eintritt; die Methode läuft bis zum ersten yield return, das <>2__current setzt, den Zustand auf 1 setzt und true zurückgibt. Das foreach, das diesen Iterator verbraucht, ruft MoveNext erneut, der switch sieht Zustand 1 und springt hinter das yield zurück, um die Schleife fortzusetzen. Wenn die Schleife schließlich endet, gibt MoveNext false zurück, was genau das ist, wozu yield break (und das Herausfallen aus dem Ende) kompiliert — das Signal an foreach, dass die Aufzählung vorbei ist.
Wie der Decompiler das yield zurückbringt
Ein Decompiler, der die ausgelieferte Assembly liest, sieht die generierte Klasse, nicht Ihre Methode. Den Iterator wiederzugewinnen ist Mustererkennung auf einer klar definierten Form:
- Die Zustandsmaschine identifizieren. Ein verschachtelter
[CompilerGenerated]-Typ, derIEnumerator<T>mit einem<>1__state- und einem<>2__current-Feld implementiert, ist die Signatur eines Iteratorblocks — verschieden von einer Async-Zustandsmaschine, dieIAsyncStateMachineimplementiert und von Awaitern stattMoveNextangetrieben wird. - Die Locals herunter-ziehen. Felder wie
<i>5__1werden zurück auf eine Localeiabgebildet, und Felder, die den ursprünglichen Parametern (max) entsprechen, werden als Parameter der rekonstruierten Methode erkannt. Die Codierung<name>5__nmacht den ursprünglichen Bezeichner wiedergewinnbar. - Den switch in Kontrollfluss falten. Jeder
casein derMoveNext-Verteilung ist ein Fortsetzungspunkt, der eineryield return-Stelle entspricht. Der Decompiler näht den Code vor dem yield und den nach der Fortsetzung zurück zu einem linearen Körper und verwandelt<>2__current = x; <>1__state = n; return true;zurück inyield return x;und das abschließendereturn false;in das impliziteyield break.
Das Ergebnis ist wieder Ihre Evens-Methode — eine for-Schleife mit einem yield return i darin — statt einer 60-zeiligen Zustandsmaschine. Glass.NET macht diese Rekonstruktion standardmäßig und lässt Sie zur rohen <Evens>d__0-Ansicht hinabsteigen, wenn Sie die tatsächliche generierte Maschinerie sehen wollen, was genau der Ort ist, an den Sie schauen, wenn ein Iterator sich falsch verhält.
Warum es sich zu wissen lohnt
Das rekonstruierte yield return ist fast immer das, was Sie wollen, aber die Zustandsmaschine darunter erklärt Verhalten, das die Hochsprachenansicht verbirgt. Die verzögerte Ausführung wird offensichtlich: Die Stub-Methode konstruiert nur die Klasse, also läuft kein Code im Iteratorkörper vor dem ersten MoveNext — weshalb eine „in” einem Iterator geworfene Ausnahme erst auftaucht, wenn Sie zu aufzählen beginnen. Der Zustand pro Enumerator ist als Instanzfelder sichtbar, was erklärt, warum zwei foreach-Schleifen über denselben Iterator jeweils ihren eigenen unabhängigen Durchlauf bekommen. Und wenn Sie die kompilierte Bibliothek eines anderen lesen — oder Ihre eigene ohne Symbole — sagt Ihnen das Erkennen von <...>d__ mit einem Zustand/current-Paar sofort, dass Sie einen Iterator ansehen, und die case-Labels in MoveNext bilden sich eins zu eins auf die yield-Punkte des Originals ab. Ein Decompiler, der das Muster kennt, gibt Ihnen den lesbaren Quellcode; das Muster selbst zu kennen lässt Sie die Maschine lesen, wenn es darauf ankommt.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.