Iteratory

Prawie każdy program, który piszesz, będzie musiał iterować w kolekcji. Napiszesz kod, który analizuje każdy element w kolekcji.

Utworzysz również metody iteracyjne, które są metodami, które tworzą iterator dla elementów tej klasy. Iterator to obiekt, który przechodzi przez kontener, szczególnie listy. Iteratory mogą służyć do:

  • Wykonywanie akcji dla każdego elementu w kolekcji.
  • Wyliczanie kolekcji niestandardowej.
  • Rozszerzanie linQ lub innych bibliotek.
  • Tworzenie potoku danych, w którym dane przepływa wydajnie za pośrednictwem metod iteratora.

Język C# udostępnia funkcje zarówno do generowania, jak i używania sekwencji. Sekwencje te mogą być tworzone i zużywane synchronicznie lub asynchronicznie. Ten artykuł zawiera omówienie tych funkcji.

Iterowanie z foreach

Wyliczanie kolekcji jest proste: foreach słowo kluczowe wylicza kolekcję, wykonując instrukcję osadzoną raz dla każdego elementu w kolekcji:

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

To wszystko. Aby iterować całą zawartość kolekcji, foreach instrukcja jest potrzebna. Instrukcja foreach nie jest jednak magią. Opiera się on na dwóch interfejsach ogólnych zdefiniowanych w bibliotece .NET Core w celu wygenerowania kodu niezbędnego do iterowania kolekcji: IEnumerable<T> i IEnumerator<T>. Ten mechanizm wyjaśniono bardziej szczegółowo poniżej.

Oba te interfejsy mają również niegeneryczne odpowiedniki: IEnumerable i IEnumerator. Wersje ogólne są preferowane dla nowoczesnego kodu.

Gdy sekwencja jest generowana asynchronicznie, możesz użyć await foreach instrukcji , aby asynchronicznie wykorzystać sekwencję:

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

Gdy sekwencja jest elementem System.Collections.Generic.IEnumerable<T>, należy użyć polecenia foreach. Gdy sekwencja jest elementem System.Collections.Generic.IAsyncEnumerable<T>, należy użyć polecenia await foreach. W tym drugim przypadku sekwencja jest generowana asynchronicznie.

Źródła wyliczenia z metodami iteratora

Kolejna świetna funkcja języka C# umożliwia tworzenie metod tworzących źródło dla wyliczenia. Te metody są określane jako metody iteracyjne. Metoda iteratora definiuje sposób generowania obiektów w sekwencji po żądaniu. Słowa kluczowe kontekstowe służą yield return do definiowania metody iteratora.

Tę metodę można napisać, aby utworzyć sekwencję liczb całkowitych z zakresu od 0 do 9:

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;
}

Powyższy kod pokazuje odrębne yield return instrukcje, aby podkreślić fakt, że można użyć wielu instrukcji dyskretnych yield return w metodzie iteratora. Można (i często) używać innych konstrukcji językowych, aby uprościć kod metody iteratora. Poniższa definicja metody tworzy dokładnie tę samą sekwencję liczb:

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

Nie musisz decydować o jednym lub drugim. Aby spełnić potrzeby metody, możesz mieć dowolną liczbę yield return instrukcji:

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

    yield return 50;

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

Wszystkie powyższe przykłady miałyby asynchroniczny odpowiednik. W każdym przypadku należy zastąpić zwracany typ IEnumerable<T> elementu ciągiem IAsyncEnumerable<T>. Na przykład w poprzednim przykładzie będzie następująca wersja asynchroniczna:

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++;
}

Jest to składnia zarówno synchronicznych, jak i asynchronicznych iteratorów. Rozważmy rzeczywisty przykład świata. Wyobraź sobie, że korzystasz z projektu IoT, a czujniki urządzeń generują bardzo duży strumień danych. Aby uzyskać informacje o danych, możesz napisać metodę, która próbkuje każdy element danych Nth. Ta mała metoda iteratora wykonuje sztuczkę:

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;
    }
}

Jeśli odczyt z urządzenia IoT generuje sekwencję asynchroniczną, zmodyfikuj metodę, jak pokazano w następującej metodzie:

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;
    }
}

Istnieje jedno ważne ograniczenie dotyczące metod iteratora: nie można mieć zarówno return instrukcji, jak i yield return instrukcji w tej samej metodzie. Następujący kod nie zostanie skompilowany:

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;
}

To ograniczenie zwykle nie jest problemem. Możesz użyć metody yield return w całej metodzie lub rozdzielić oryginalną metodę na wiele metod, niektóre przy użyciu metody i niektóre przy użyciu returnmetody yield return.

Możesz nieco zmodyfikować ostatnią metodę, aby używać yield return wszędzie:

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;
}

Czasami właściwą odpowiedzią jest podzielenie metody iteratora na dwie różne metody. Jedna, która używa elementu return, i drugiego, który używa elementu yield return. Rozważ sytuację, w której możesz zwrócić pustą kolekcję lub pięć pierwszych nieparzystnych liczb na podstawie argumentu logicznego. Możesz napisać to jako następujące dwie metody:

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++;
    }
}

Przyjrzyj się powyższym metodom. Pierwszy używa standardowej return instrukcji, aby zwrócić pustą kolekcję lub iterator utworzony przez drugą metodę. Druga metoda używa instrukcji , yield return aby utworzyć żądaną sekwencję.

Dokładniejsze omówienie foreach

Instrukcja foreach rozszerza się na standardowy idiom, który używa IEnumerable<T> interfejsów i IEnumerator<T> do iterowania wszystkich elementów kolekcji. Minimalizuje również błędy, które deweloperzy popełniają, nie prawidłowo zarządzając zasobami.

Kompilator tłumaczy pętlę foreach pokazaną w pierwszym przykładzie na podobną do tej konstrukcji:

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

Dokładny kod generowany przez kompilator jest bardziej skomplikowany i obsługuje sytuacje, w których obiekt zwracany przez GetEnumerator() implementację interfejsu IDisposable . Pełne rozszerzenie generuje kod bardziej podobny do następującego:

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

Kompilator tłumaczy pierwszy przykład asynchroniczny na coś podobnego do tej konstrukcji:

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

Sposób, w jaki moduł wyliczający jest usuwany, zależy od cech typu enumerator. W ogólnym przypadku synchronicznym klauzula finally rozszerza się na:

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

Ogólny przypadek asynchroniczny jest rozszerzany na:

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

Jeśli jednak typ enumerator jest typu zapieczętowanego i nie ma niejawnej konwersji z typu enumerator na IDisposable lub IAsyncDisposable, finally klauzula rozszerza się do pustego bloku:

finally
{
}

Jeśli istnieje niejawna konwersja z typu enumerator na , i enumerator jest typem wartości innej niż null, klauzula finallyIDisposablerozszerza się na:

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

Na szczęście nie musisz pamiętać wszystkich tych szczegółów. Instrukcja foreach obsługuje wszystkie te niuanse. Kompilator wygeneruje prawidłowy kod dla dowolnej z tych konstrukcji.