Iterators

Bei fast jedem Programm, das Sie schreiben, muss eine Auflistung durchlaufen werden. Sie schreiben Code, der jedes Element in einer Auflistung überprüft.

Sie erstellen auch Iteratormethoden, die einen Iterator für die Elemente dieser Klasse erzeugen. Ein Iterator ist ein Objekt, das einen Container durchläuft, insbesondere Listen. Iteratoren können für Folgendes verwendet werden:

  • Ausführen einer Aktion für jedes Element in einer Auflistung
  • Enumerieren einer benutzerdefinierten Auflistung
  • Erweitern von LINQ oder anderen Bibliotheken
  • Erstellen einer Datenpipeline, in der Daten Iteratormethoden effizient durchlaufen

Die Programmiersprache C# bietet Funktionen zum Generieren und Verarbeiten von Sequenzen. Diese Sequenzen können synchron oder asynchron erzeugt und verarbeitet werden. Dieser Artikel enthält eine Übersicht über diese Funktionen.

Durchlaufen mit foreach

Das Enumerieren einer Auflistung ist einfach: Das foreach-Schlüsselwort enumeriert eine Auflistung und führt die eingebettete Anweisung einmal für jedes Element in der Auflistung aus:

foreach (var item in collection)
{
    Console.WriteLine(item?.ToString());
}

Das ist alles. Für das Durchlaufen aller Inhalte einer Auflistung benötigen Sie nur die foreach-Anweisung. Die foreach-Anweisung ist jedoch nicht magisch. Sie beruht auf zwei generischen Schnittstellen, die in der .NET Core-Bibliothek definiert sind, um den Code für das Durchlaufen einer Auflistung zu generieren: IEnumerable<T> und IEnumerator<T>. Dieser Mechanismus wird unten ausführlich erläutert.

Für diese beiden Schnittstellen gibt es auch nicht generische Entsprechungen: IEnumerable und IEnumerator. Die generischen Versionen werden für modernen Code bevorzugt.

Wenn eine Sequenz asynchron generiert wird, können Sie die await foreach-Anweisung verwenden, um die Sequenz asynchron zu verarbeiten:

await foreach (var item in asyncSequence)
{
Console.WriteLine(item?.ToString());
}

Wenn eine Sequenz ein System.Collections.Generic.IEnumerable<T> ist, verwenden Sie foreach. Wenn eine Sequenz ein System.Collections.Generic.IAsyncEnumerable<T> ist, verwenden Sie await foreach. Im letzteren Fall wird die Sequenz asynchron generiert.

Enumerationsquellen mit Iteratormethoden

Mit einer weiteren großartigen Funktion der Programmiersprache C# können Sie Methoden konstruieren, die eine Quelle für eine Enumeration erstellen. Diese Methoden werden als Iteratormethoden bezeichnet. Eine Iteratormethode definiert, wie die Objekte in einer Sequenz bei Anforderung generiert werden. Sie verwenden die yield return-Kontextschlüsselwörter, um eine Iteratormethode zu definieren.

Sie könnten diese Methode schreiben, um die Sequenz von ganzen Zahlen von 0 bis 9 zu erstellen:

public IEnumerable<int> GetSingleDigitNumbers()
{
    yield return 0;
    yield return 1;
    yield return 2;
    yield return 3;
    yield return 4;
    yield return 5;
    yield return 6;
    yield return 7;
    yield return 8;
    yield return 9;
}

Der obige Code zeigt verschiedene yield return-Anweisungen, die verdeutlichen, dass Sie mehrere diskrete yield return-Anweisungen in einer Iteratormethode verwenden können. Sie können (und werden auch oft) andere Sprachkonstrukte zur Vereinfachung des Codes einer Iteratormethode verwenden. Die folgende Methodendefinition erzeugt genau dieselbe Sequenz von Zahlen:

public IEnumerable<int> GetSingleDigitNumbersLoop()
{
    int index = 0;
    while (index < 10)
        yield return index++;
}

Sie müssen sich nicht für eine davon entscheiden. Sie können so viele yield return-Anweisungen verwenden wie für Ihre Methode erforderlich:

public IEnumerable<int> GetSetsOfNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    index = 100;
    while (index < 110)
        yield return index++;
}

Alle obigen Beispiele hätten eine asynchrone Entsprechung. In jedem Fall ersetzen Sie den Rückgabetyp von IEnumerable<T> durch ein IAsyncEnumerable<T>. Das vorherige Beispiel würde beispielsweise die folgende asynchrone Version aufweisen:

public async IAsyncEnumerable<int> GetSetsOfNumbersAsync()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    await Task.Delay(500);

    yield return 50;

    await Task.Delay(500);

    index = 100;
    while (index < 110)
        yield return index++;
}

Dies ist die Syntax für synchrone und asynchrone Iteratoren. Sehen wir uns ein reales Beispiel an. Angenommen, Sie arbeiten an einem IoT-Projekt und die Gerätesensoren generieren einen sehr großen Datenstrom. Um ein Gefühl für die Daten zu bekommen, können Sie eine Methode schreiben, die bei jedem n-ten Datenelement eine Stichprobe durchführt. Diese kleine Iteratormethode ist der Trick dabei:

public static IEnumerable<T> Sample<T>(this IEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

Wenn der Messwert vom IoT-Gerät eine asynchrone Sequenz erzeugt, ändern Sie die Methode wie im Folgenden dargestellt:

public static async IAsyncEnumerable<T> Sample<T>(this IAsyncEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    await foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

Es gibt eine wichtige Einschränkung bei Iteratormethoden: Eine return-Anweisung und eine yield return-Anweisung können nicht in derselben Methode enthalten sein. Der folgende Code wird nicht kompiliert:

public IEnumerable<int> GetSingleDigitNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    // generates a compile time error:
    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    return items;
}

Diese Einschränkung ist normalerweise kein Problem. Sie können in der Methode entweder durchgehend yield return verwenden oder die ursprüngliche Methode in mehrere Methoden aufteilen, von denen einige return und einige yield return verwenden.

Sie können die letzte Methode leicht ändern und so yield return überall verwenden:

public IEnumerable<int> GetFirstDecile()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    foreach (var item in items)
        yield return item;
}

Manchmal ist die beste Lösung, eine Iteratormethode in zwei verschiedene Methoden aufzuteilen. Die eine verwendet dann return und die zweite verwendet yield return. Betrachten Sie eine Situation, in der Sie eine leere Auflistung oder die ersten fünf ungeraden Zahlen basierend auf einem booleschen Argument zurückgeben möchten. Sie können das mit diesen zwei Methoden schreiben:

public IEnumerable<int> GetSingleDigitOddNumbers(bool getCollection)
{
    if (getCollection == false)
        return new int[0];
    else
        return IteratorMethod();
}

private IEnumerable<int> IteratorMethod()
{
    int index = 0;
    while (index < 10)
    {
        if (index % 2 == 1)
            yield return index;
        index++;
    }
}

Betrachten Sie die oben genannten Methoden. Die erste Methode verwendet die return-Standardanweisung, um entweder eine leere Auflistung oder den Iterator, der durch die zweite Methode erstellt wurde, zurückzugeben. Die zweite Methode verwendet die yield return-Anweisung, um die angeforderte Reihenfolge zu erstellen.

Tieferer Einblick in foreach

Die foreach-Anweisung wird in einen Standardausdruck erweitert, der die Schnittstellen IEnumerable<T> und IEnumerator<T> für das Durchlaufen aller Elemente einer Auflistung verwendet. Außerdem werden Fehler minimiert, die Entwickler durch falsche Ressourcenverwaltung verursachen.

Der Compiler übersetzt die im ersten Beispiel gezeigte foreach-Schleife in etwas wie dieses Konstrukt:

IEnumerator<int> enumerator = collection.GetEnumerator();
while (enumerator.MoveNext())
{
    var item = enumerator.Current;
    Console.WriteLine(item.ToString());
}

Der genaue vom Compiler generierte Code ist komplizierter und behandelt Situationen, in denen das von GetEnumerator() zurückgegebene Objekt die IDisposable-Schnittstelle implementiert. Der durch vollständige Erweiterung generierte Code sieht eher wie folgt aus:

{
    var enumerator = collection.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of enumerator.
    }
}

Der Compiler übersetzt das erste asynchrone Beispiel in ein Konstrukt wie das folgende:

{
    var enumerator = collection.GetAsyncEnumerator();
    try
    {
        while (await enumerator.MoveNextAsync())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of async enumerator.
    }
}

Die Art und Weise, wie der Enumerator verworfen wird, hängt von den Merkmalen des Typs von enumerator ab. Im allgemeinen synchronen Fall wird die finally-Klausel wie folgt erweitert:

finally
{
   (enumerator as IDisposable)?.Dispose();
}

Im allgemeinen asynchronen Fall sieht die Erweiterung wie folgt aus:

finally
{
    if (enumerator is IAsyncDisposable asyncDisposable)
        await asyncDisposable.DisposeAsync();
}

Wenn es sich bei dem Typ von enumerator jedoch um einen versiegelten Typ handelt und es keine implizite Konvertierung vom Typ von enumerator zu IDisposable oder IAsyncDisposable gibt, wird die finally-Klausel zu einem leeren Block erweitert:

finally
{
}

Wenn es eine implizite Konvertierung des Typs von enumerator zu IDisposable gibt und enumerator keine NULL-Werte zulässt, wird die finally-Klausel wie folgt erweitert:

finally
{
   ((IDisposable)enumerator).Dispose();
}

Glücklicherweise müssen Sie sich nicht alle diese Details merken. Die foreach-Anweisung kümmert sich für Sie um alle diese Nuancen. Der Compiler generiert den korrekten Code für jedes dieser Konstrukte.