Ключевое слово yield
позволяет создать IEnumerable<T>
в форме в блоке итератора . Этот блок итератора поддерживает отложенное выполнение , и если вы не знакомы с концепцией, он может показаться почти волшебным. Однако, в конце концов, это просто код, который выполняется без каких-либо странных уловок.
Блок итератора можно описать как синтаксический сахар, где компилятор генерирует конечный автомат, который отслеживает, как далеко продвинулось перечисление перечисляемого. Чтобы перечислить перечислимое, вы часто используете цикл foreach
. Однако петля foreach
также является синтаксическим сахаром. Таким образом, вы удалили две абстракции из реального кода, поэтому поначалу может быть трудно понять, как все это работает вместе.
Предположим, у вас есть очень простой блок итератора:
IEnumerable<int> IteratorBlock()
{
Console.WriteLine("Begin");
yield return 1;
Console.WriteLine("After 1");
yield return 2;
Console.WriteLine("After 2");
yield return 42;
Console.WriteLine("End");
}
Реальные блоки итераторов часто имеют условия и циклы, но когда вы проверяете условия и разворачиваете циклы, они все равно заканчиваются выражениями yield
, чередующимися с другим кодом.
Для перечисления блока итератора используется цикл foreach
:
foreach (var i in IteratorBlock())
Console.WriteLine(i);
Вот вывод (здесь никаких сюрпризов):
Begin
1
After 1
2
After 2
42
End
Как указано выше foreach
является синтаксическим сахаром:
IEnumerator<int> enumerator = null;
try
{
enumerator = IteratorBlock().GetEnumerator();
while (enumerator.MoveNext())
{
var i = enumerator.Current;
Console.WriteLine(i);
}
}
finally
{
enumerator?.Dispose();
}
В попытке распутать это я создал диаграмму последовательности с удаленными абстракциями:
Конечный автомат, сгенерированный компилятором, также реализует перечислитель, но чтобы сделать диаграмму более понятной, я показал их как отдельные экземпляры. (Когда конечный автомат перечисляется из другого потока, вы фактически получаете отдельные экземпляры, но эта деталь здесь не важна.)
Каждый раз, когда вы вызываете свой блок итератора, создается новый экземпляр конечного автомата. Однако ни один из вашего кода в блоке итератора не будет выполнен, пока enumerator.MoveNext()
не будет выполнен в первый раз. Вот как работает отложенное выполнение. Вот (довольно глупый) пример:
var evenNumbers = IteratorBlock().Where(i => i%2 == 0);
На данный момент итератор не выполнен. Предложение Where
создает новый IEnumerable<T>
, который оборачивает IEnumerable<T>
, возвращаемый IteratorBlock
, но это перечисляемое еще предстоит перечислить. Это происходит при выполнении цикла foreach
:
foreach (var evenNumber in evenNumbers)
Console.WriteLine(eventNumber);
Если вы перечислите перечислимое дважды дважды, каждый раз создается новый экземпляр конечного автомата, и ваш блок итератора будет выполнять один и тот же код дважды.
Обратите внимание, что методы LINQ, такие как ToList()
, ToArray()
, First()
, Count()
и т. Д., Будут использовать цикл foreach
для перечисления перечислимого. Например, ToList()
перечислит все элементы перечислимого и сохранит их в списке. Теперь вы можете получить доступ к списку, чтобы получить все элементы перечислимого без повторного выполнения блока итератора. Существует компромисс между использованием ЦП для создания элементов перечисляемого множества раз и памяти для хранения элементов перечисления для многократного доступа к ним при использовании таких методов, как ToList()
.