Итераторы
Почти каждой написанной вами программе придется выполнять итерацию определенной коллекции. Для этого вы напишете код, проверяющий каждый элемент в коллекции.
Кроме того, вы создадите методы итератора, то есть методы, которые создают итератор для элементов соответствующего класса. Итератор — это объект, который выполняет обход контейнера, в частности списков. Итераторы можно использовать для следующих целей:
- Выполнение определенного действия с каждым элементом в коллекции.
- Перечисление настраиваемой коллекции.
- Расширение 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
обрабатывает все эти нюансы в фоновом режиме, а компилятор создает правильный код для любой из этих конструкций.