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


Итераторы

Почти каждой написанной вами программе придется выполнять итерацию определенной коллекции. Для этого вы напишете код, проверяющий каждый элемент в коллекции.

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

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

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

Итерация для каждого

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

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

Вот и все. Для итерации содержимого той или иной коллекции нужен только это оператор foreach. При этом в работе оператора foreach нет ничего сложного. Он создает код, необходимый для итерации коллекции, опираясь на два универсальных интерфейса, определенных в библиотеке ядра .NET: 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++;
}

Это синтаксис применим как для синхронных, так и для асинхронных итераторов. Давайте рассмотрим практический пример. Допустим, вы занимаетесь проектом IoT и имеете дело с датчиками устройств, которые создают большой поток данных. Чтобы получить представление об этих данных, можно написать метод, формирующий выборку из каждого 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 обрабатывает все эти нюансы в фоновом режиме, а компилятор создает правильный код для любой из этих конструкций.