Jede foreach-Schleife und jede LINQ-Abfrage arbeitet über ein kleines Interface: IEnumerable<T>. Wer es versteht, versteht, warum LINQ verzögert arbeitet, warum manche Sequenzen nur einmal gelesen werden können und wie du mit yield return eigene Sequenzen mit einer einfachen Schleife schreibst.
Was IEnumerable ist
IEnumerable<T> hat eine einzige Methode, GetEnumerator(). Der zurückgegebene Enumerator hat MoveNext() (weitergehen und melden, ob es ein Element gibt) und Current (das Element). Ein foreach ist eine Kurzform, um diesen Enumerator anzutreiben:
Ausgabe:
red green blue
red green blue
Der Enumerator beginnt vor dem ersten Element, das erste MoveNext() bewegt ihn also darauf. Gibt MoveNext() false zurück, ist die Sequenz zu Ende. Der using-Block gibt den Enumerator frei, was bei Iteratoren zählt, die Ressourcen wie offene Dateien halten.
IEnumerable gegenüber ICollection gegenüber List
Die Collection-Interfaces bilden eine Leiter. Jede Stufe verspricht mehr:
| Typ | Fügt hinzu | Typischer Einsatz |
|---|---|---|
IEnumerable<T> | nur foreach | Parameter, die du nur durchläufst; LINQ-Ergebnisse; verzögerte Sequenzen |
IReadOnlyCollection<T> | Count | Rückgabewerte, die der Aufrufer nicht ändern soll |
ICollection<T> | Count, Add, Remove, Contains | Code, der einer beliebigen Collection etwas hinzufügt |
IReadOnlyList<T> | Indexzugriff [i] | Schreibgeschützte Ergebnisse mit Positionen |
IList<T> | Indexzugriff, Insert, RemoveAt | Code, der nach Position bearbeitet |
List<T> | Die konkrete Klasse, die all das implementiert | Speicherung |
Eine Methode, die ihre Eingabe nur durchläuft, sollte IEnumerable<T> annehmen, damit Aufrufer ein Array, eine Liste, ein Set oder eine LINQ-Abfrage ohne Umwandlung übergeben können. Eine Methode, die Daten zurückgibt, sollte meist etwas zurückgeben, das sagt „das liegt schon im Speicher“, etwa List<T> oder IReadOnlyList<T>, denn ein zurückgegebenes IEnumerable<T> könnte eine Abfrage sein, die bei jedem Lesen Arbeit verrichtet.
Ausgabe:
30
15
4.0
30
Einen Iterator mit yield return schreiben
Um eine eigene Sequenz zu erzeugen, schreibe eine Methode, die IEnumerable<T> zurückgibt, und verwende yield return für jedes Element. Der Compiler macht aus der Methode eine Klasse, die den Enumerator für dich implementiert.
Ausgabe:
0, 2, 4, 6, 8, 10
Mon Tue Wed Thu Fri
In EvenNumbers gibt es nirgends eine Liste. Jedes yield return übergibt dem Aufrufer einen Wert und pausiert die Methode, wobei ihre lokalen Variablen (hier i) am Leben bleiben, bis der Aufrufer den nächsten Wert anfordert.
Verzögerung: auf die Ausführungsreihenfolge achten
Weil die Methode pausiert, wechseln sich ihr Rumpf und die Schleife des Aufrufers ab. Ausgaben von beiden Seiten zeigen die Verschränkung:
Ausgabe:
Called Numbers(), nothing printed yet
start
yielding 1
Loop got 1
resumed after 1
yielding 2
Loop got 2
resumed after 2
yielding 3
Loop got 3
resumed after 3
end
Der Aufruf von Numbers() hat nicht „start“ ausgegeben: Er hat nur das Enumerable erzeugt. Der Rumpf begann beim ersten MoveNext() im foreach, lief bis zum ersten yield return und hielt an. Jeder spätere Schritt ging in der Zeile nach dem yield weiter. Where und Select aus LINQ verhalten sich genauso (die Runtime implementiert sie aus Geschwindigkeitsgründen mit handgeschriebenen Enumerator-Klassen, aber der Effekt ist identisch), deshalb sind LINQ-Abfragen verzögert.
yield break
yield break beendet die Sequenz vorzeitig, die Iterator-Version von return:
Ausgabe:
Subject: Invoice
From: shop@example.com
return value; und yield return lassen sich in einer Methode nicht mischen. Sobald eine Methode yield enthält, ist sie ein Iterator, und yield break ist der einzige Weg, sie vorzeitig zu verlassen.
Unendliche Sequenzen
Da immer nur die angeforderten Elemente berechnet werden, kann ein Iterator eine Sequenz ohne Ende beschreiben. Der Verbraucher entscheidet, wie viel er nimmt:
Ausgabe:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34
1597
2026-09-28 Mon
2026-10-05 Mon
2026-10-12 Mon
while (true) ist hier sicher, weil Take und First aufhören zu fragen, sobald sie haben, was sie brauchen. ToList() oder Count() auf einer unendlichen Sequenz kehren dagegen nie zurück, und das Durchlaufen eines OrderBy darüber auch nicht, weil Sortieren zuerst jedes Element braucht.
Fallen verzögerter Sequenzen
Argumentprüfungen laufen spät. Der gesamte Rumpf eines Iterators wird verzögert, auch die Validierung am Anfang. Die Lösung ist, die Methode aufzuteilen: Eine normale Methode prüft die Argumente und gibt das Ergebnis eines privaten Iterators zurück.
Ausgabe:
LazyRange(-1) returned without an error
...and threw only when enumerated
EagerRange(-1) threw at the call
Jedes Durchlaufen führt die Methode erneut aus. Ein Iterator speichert nichts zwischen. Zweimal über Numbers() zu laufen gibt „start“ zweimal aus, liest die Datei zweimal oder ruft den Webdienst zweimal auf. IDEs markieren das als „possible multiple enumeration of IEnumerable“. Rufe einmal ToList() auf, wenn du die Elemente mehr als einmal brauchst.
finally läuft, wenn der Verbraucher aufhört. Verlässt der Aufrufer das foreach vorzeitig, wird der Enumerator freigegeben, und jeder finally-Block im Iterator läuft. So schließt ein Iterator, der eine Datei liest, diese auch:
Ausgabe:
open sensor
10
20
30
close sensor
done
Ein paar Regeln, die der Compiler bei Iteratoren durchsetzt: yield return darf nicht in einem try-Block mit catch stehen (ein try/finally ist in Ordnung), nicht in einem Lambda oder einer anonymen Methode und nicht in einer Methode mit Parametern, die ref oder out verwenden.
Asynchrone Streams
Für Sequenzen, deren Elemente asynchron ankommen (Seiten einer API, Zeilen aus einer Datenbank), kombinieren C# 8 und neuer (mit .NET Core 3.0 oder neuer) Iteratoren über IAsyncEnumerable<T> mit await:
static async IAsyncEnumerable<string> FetchPages()
{
for (int page = 1; page <= 3; page++)
{
await Task.Delay(100); // simulate a network call
yield return $"page {page}";
}
}
await foreach (string p in FetchPages())
{
Console.WriteLine(p);
}
Der Verbraucher verwendet await foreach, und jedes Element wird abgewartet, sobald es erzeugt wird.
Häufige Fehler
IEnumerable<T>aus einer Abfrage zurückgeben und es oft lesen. Jedes Lesen führt sie erneut aus; gib stattdessen eineList<T>zurück.- Argumente in einem Iterator validieren. Die Prüfung wird verzögert; teile in einen Wrapper und einen Iterator auf.
Count()oderToList()auf einem unendlichen Iterator aufrufen. Es endet nie; nimm zuerstTake.- Erwarten, dass ein
IEnumerable<T>-ParameterCountoder[i]hat. NimmCount()aus LINQ (das möglicherweise durchläuft) oder akzeptiereIReadOnlyList<T>.
Häufig gestellte Fragen
Was ist IEnumerable in C#?
IEnumerable<T> ist das Interface für alles, was sich mit foreach durchlaufen lässt. Es hat eine Methode, GetEnumerator(), die einen IEnumerator<T> mit MoveNext() und Current zurückgibt. Arrays, Listen, Dictionaries, Sets, LINQ-Abfragen und Iteratormethoden implementieren es alle.
Was ist der Unterschied zwischen IEnumerable und List in C#?
IEnumerable<T> verspricht nur, dass du iterieren kannst; es hat keine Property Count, keinen Indexer und kein Add, und es wird möglicherweise bei jeder Schleife neu und verzögert berechnet. List<T> ist eine konkrete, im Speicher abgelegte Collection mit all dem. Nimm IEnumerable<T> als Parameter, wenn du nur durchläufst, und gib eine List<T> oder IReadOnlyList<T> zurück, wenn Aufrufer indizieren oder zählen müssen.
Was macht yield return in C#?
yield return value; in einer Methode, die IEnumerable<T> zurückgibt, übergibt dem Aufrufer ein Element und pausiert die Methode an dieser Stelle. Wenn der Aufrufer das nächste Element anfordert, macht die Methode in der nächsten Zeile weiter. Der Compiler schreibt die Methode in einen Zustandsautomaten um, du schreibst also eine Schleife und bekommst eine verzögerte Sequenz.
Was ist yield break in C#?
yield break; beendet die Sequenz: Das foreach des Aufrufers endet normal nach den bereits erzeugten Elementen. Es ist das Gegenstück zu return für Iteratoren und wird verwendet, um früh aufzuhören, etwa wenn ein Limit erreicht oder die Eingabe leer ist.
Warum wirft meine Iteratormethode keine Exception, wenn ich sie aufrufe?
Der Rumpf einer Iteratormethode läuft erst, wenn die Sequenz durchlaufen wird, Argumentprüfungen an ihrem Anfang laufen also beim ersten MoveNext und nicht beim Aufruf. Um sofort zu validieren, setze die Prüfungen in eine normale öffentliche Methode, die dann eine private Iteratormethode mit den yield-Anweisungen aufruft.