Поделиться через


Итераторы

Почти в каждой написанной вами программе возникнет необходимость в итерации по коллекции. Вы напишете код, который проверяет каждый элемент в коллекции.

Вы также создадите метод итератора, который создаёт итератор для элементов этого класса. Итератор — это объект, который проходит через контейнер, особенно списки. Итераторы можно использовать для:

  • Выполнение действия для каждого элемента в коллекции.
  • Перечисление коллекции, определенной пользователем.
  • Расширение LINQ или других библиотек.
  • Создание конвейера данных, в котором данные эффективно передаются с помощью методов итератора.

Язык C# предоставляет функции для создания и использования последовательностей. Эти последовательности можно производить и использовать синхронно или асинхронно. В этой статье представлен обзор этих функций.

Итерация с использованием foreach

Перечисление коллекции - это просто: ключевое слово foreach перечисляет коллекцию, выполняя внедренную инструкцию один раз для каждого элемента коллекции.

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

Это всё. Чтобы выполнить итерацию по всем элементам коллекции, достаточно foreach инструкции. Это не магия foreach , хотя. Он использует два универсальных интерфейса, определенных в библиотеке .NET Core, чтобы создать код, необходимый для итерации коллекции: IEnumerable<T> и IEnumerator<T>. Этот механизм более подробно описан ниже.

Оба этих интерфейса также имеют не универсальные аналоги: IEnumerable и IEnumerator. Универсальные версии предпочтительнее для современного кода.

При асинхронной генерации последовательности можно использовать await foreach инструкцию для асинхронного потребления последовательности.

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

Если последовательность является последовательностью System.Collections.Generic.IEnumerable<T>, используется foreach. Если последовательность является последовательностью System.Collections.Generic.IAsyncEnumerable<T>, используется await foreach. В последнем случае последовательность создается асинхронно.

Источники перечисления с методами итератора

Еще одна отличная функция языка C# позволяет создавать методы, создающие источник перечисления. Эти методы называются методами итератора. Метод итератора определяет, как создавать объекты в последовательности при запросе. Вы используете yield return контекстные ключевые слова для определения метода итератора.

Этот метод можно написать для создания последовательности целых чисел от 0 до 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;
}

В приведенном выше коде показаны отдельные yield return инструкции, чтобы выделить тот факт, что в методе итератора можно использовать несколько дискретных yield return инструкций. Вы можете (и часто) использовать другие конструкции языка, чтобы упростить код метода итератора. Определение метода, приведенное ниже, создает ту же последовательность чисел:

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

Вам не нужно решать одну или другую. Вы можете иметь столько yield return вставок, сколько необходимо для удовлетворения потребностей вашего метода:

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

    yield return 50;

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

Все эти предыдущие примеры имеют асинхронный аналог. В каждом случае вы замените тип возвращаемого IEnumerable<T> на IAsyncEnumerable<T>. Например, предыдущий пример будет иметь следующую асинхронную версию:

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

Это синтаксис синхронных и асинхронных итераторов. Рассмотрим реальный пример. Представьте, что вы находитесь в проекте Интернета вещей, и датчики устройств создают очень большой поток данных. Чтобы получить представление о данных, можно написать метод, который берёт каждый N-й элемент данных. Этот небольшой метод итератора решает задачу.

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

Если чтение с устройства Интернета вещей создаст асинхронную последовательность, измените метод, как показано ниже.

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

Существует одно важное ограничение для методов итератора: вы не можете использовать в одном методе одновременно оператор return и оператор yield return. Следующий код не компилируется:

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

Обычно это ограничение не является проблемой. У вас есть выбор: либо использовать yield return во всем методе, либо разделить исходный метод на несколько методов, некоторые из которых используют return, а другие — yield return.

Вы можете немного изменить последний метод, чтобы использовать yield return везде:

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

Иногда правильный ответ — разделить метод итератора на два разных метода. Одна использует return, а вторая использует yield return. Рассмотрим ситуацию, когда требуется вернуть пустую коллекцию или первые пять нечетных чисел, в зависимости от значения логического аргумента. Вы могли бы записать это как два метода:

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

Ознакомьтесь с приведенными выше методами. Первый использует стандартную return инструкцию для возврата пустой коллекции или итератора, созданного вторым методом. Второй метод использует инструкцию yield return для создания запрошенной последовательности.

Более глубокое погружение в foreach

Инструкция foreach разворачивается в стандартную идиому, которая использует интерфейсы IEnumerable<T> и IEnumerator<T> для итерации по всем элементам коллекции. Она также сводит к минимуму ошибки разработчиков, не управляя ресурсами должным образом.

Компилятор преобразует цикл, показанный foreach в первом примере, в что-то похожее на эту конструкцию:

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

Точный код, созданный компилятором, более сложен и обрабатывает ситуации, когда объект, возвращаемый GetEnumerator(), реализует интерфейс IDisposable. Полное расширение генерирует код, похожий на следующий пример.

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

Компилятор преобразует первый асинхронный пример в что-то похожее на эту конструкцию:

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

Способ удаления перечислителя зависит от характеристик типа enumerator. В общем синхронном случае finally пункт расширяется следующим образом:

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

Общий асинхронный случай расширяется до:

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

Однако если тип enumerator является запечатанным, и нет неявного преобразования из типа enumerator в IDisposable или IAsyncDisposable, то предложение finally расширяется до пустого блока.

finally
{
}

Если существует неявное преобразование из типа enumerator в IDisposable, и enumerator является типом значения, недопускающим null, формулировка finally расширяется так:

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

К счастью, вам не нужно помнить все эти детали. Оператор foreach обрабатывает все эти нюансы для вас. Компилятор создаст правильный код для любой из этих конструкций.