Итераторы и ключевое слово yield
Где yield даёт максимальную пользу
yield особенно полезен там, где источник данных:
- большой и нет смысла материализовывать всё сразу;
- поступает постепенно (файл, поток, сетевые данные);
- может быть бесконечным;
- комбинируется с LINQ-конвейером.
Классический пример: читаем большой файл построчно и фильтруем "на лету", не загружая всё в память.
Когда лучше не использовать yield
Есть ситуации, где обычный метод или явная материализация проще и понятнее:
- результат нужен многократно без повторных вычислений;
- метод имеет сложные побочные эффекты и важен момент выполнения;
- нужна случайная индексация по результату (
result[500]).
В таких случаях часто проще сразу вернуть List<T> или массив.
Диагностика и отладка
Если кажется, что "код вообще не работает", сначала проверьте, не забыли ли вы перечислить последовательность:
foreach;ToList()/ToArray();First(),Count()и т.д.
Это очень частая путаница: ожидаем выполнение в момент вызова метода, а оно происходит только при перечислении.
Связанные статьи — LINQ, Коллекции и структуры данных, Делегаты, события и обратные вызовы.
Итераторы и ключевое слово yield
Разработчику АрхитекторуИтераторы и yield
Итератор представляет собой механизм, позволяющий последовательно перебирать элементы коллекции или последовательности без необходимости загружать всю структуру данных в память целиком. Он обеспечивает ленивую (отложенную) обработку: каждый элемент вычисляется по мере необходимости, а не заранее. Такой подход особенно полезен при работе с большими или потенциально бесконечными наборами данных, где хранение всех значений одновременно привело бы к избыточному потреблению ресурсов.
В языках программирования, поддерживающих концепцию итераторов, разработчик может определить собственное поведение перебора. Это достигается через реализацию специального интерфейса или использование синтаксических конструкций, упрощающих создание итераторов. В C# такая задача решается с помощью ключевого слова yield. Паттерн Iterator в терминах GoF и сравнение ручного IEnumerator<T> с генерацией компилятором — в отдельной статье.
Ключевое слово yield
Ключевое слово yield предоставляет компилятору инструкцию автоматически сгенерировать итератор на основе метода, в котором оно используется. Метод, содержащий yield, называется итераторным методом. Он не возвращает готовую коллекцию, а описывает логику получения каждого следующего элемента. При каждом вызове итератора выполнение метода возобновляется с того места, где оно было приостановлено ранее, благодаря внутреннему состоянию, сохраняемому компилятором.
Существует две основные формы использования yield:
yield return— возвращает очередной элемент последовательности и приостанавливает выполнение метода до следующего запроса.yield break— завершает итерацию, указывая, что больше элементов нет.
Эти конструкции позволяют писать код, который читается как последовательный, но выполняется как состояние машины с возможностью приостановки и возобновления.
Принцип работы yield return
Когда метод содержит yield return, компилятор трансформирует его в класс, реализующий интерфейс IEnumerable<T> или IEnumerator<T>. Этот класс сохраняет текущее состояние выполнения — значения локальных переменных, позицию в коде, контекст цикла. При первом обращении к итератору создаётся экземпляр этого сгенерированного класса. Каждый вызов метода MoveNext() (явный или неявный, например, через foreach) приводит к продолжению выполнения метода с точки остановки до следующего yield return или yield break.
Таким образом, yield return действует как временная точка выхода из метода, после которой управление возвращается вызывающему коду, но внутреннее состояние сохраняется для будущего использования. Это позволяет эффективно моделировать потоки данных, генераторы и другие сценарии, где важна пошаговая выдача информации.
Пример простого итератора:
public static IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
Разбор:
- Фрагмент начинается с
public static IEnumerable<int> GetNumbers()и демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
IEnumerable<T>задаёт контракт перечисления и поддерживает ленивую обработку последовательности.yield returnвыдаёт элементы по одному и сохраняет состояние итератора между шагами. - Поток выполнения ленивый: метод продолжает выполнение только при запросе следующего элемента, а не целиком за один вызов.
При вызове GetNumbers() не создаётся массив [1, 2, 3]. Вместо этого возвращается объект, который при первом запросе вернёт 1, при втором — 2, при третьем — 3, а затем завершит итерацию. Память выделяется только под текущее значение и состояние итератора.
Как именно компилятор сохраняет "точку остановки" между вызовами — в разделе "Под капотом: конечный автомат" ниже.
Использование yield break
Конструкция yield break завершает итерацию досрочно. Она аналогична оператору return в обычном методе, но применяется в контексте итератора. После yield break метод прекращает выполнение, и последующие вызовы MoveNext() будут возвращать false, сигнализируя об окончании последовательности.
Пример с условным завершением:
public static IEnumerable<int> GetLimitedNumbers(int limit)
{
for (int i = 1; i <= 10; i++)
{
if (i > limit)
yield break;
yield return i;
}
}
Разбор:
- Фрагмент начинается с
public static IEnumerable<int> GetLimitedNumbers(int limit)и демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
IEnumerable<T>задаёт контракт перечисления и поддерживает ленивую обработку последовательности.yield returnвыдаёт элементы по одному и сохраняет состояние итератора между шагами.yield breakзавершает итерацию досрочно и останавливает выдачу элементов. - Поток выполнения ленивый: метод продолжает выполнение только при запросе следующего элемента, а не целиком за один вызов.
Если вызвать GetLimitedNumbers(5), итератор вернёт числа от 1 до 5, после чего остановится. Без yield break цикл продолжил бы выполнение, но yield return не был бы достигнут, что привело бы к тому же результату, однако явное использование yield break делает намерение разработчика более очевидным и улучшает читаемость.
Создание пользовательских итераторов
Пользовательские итераторы позволяют определять собственные правила перебора. Это особенно полезно при работе с древовидными структурами, графами, файловыми системами, потоками событий или любыми данными, где стандартный перебор недостаточен.
Рассмотрим пример обхода дерева в глубину:
Код ITЗагрузка примера кода…
Разбор:
- Фрагмент начинается с
public class TreeNodeи демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
List<T>используется как типобезопасная динамическая коллекция с доступом по индексу.IEnumerable<T>задаёт контракт перечисления и поддерживает ленивую обработку последовательности.yield returnвыдаёт элементы по одному и сохраняет состояние итератора между шагами. - Поток выполнения ленивый: метод продолжает выполнение только при запросе следующего элемента, а не целиком за один вызов.
Здесь метод TraverseDepthFirst рекурсивно возвращает значения узлов в порядке обхода в глубину. Благодаря yield return, каждый элемент выдаётся по мере достижения, без создания промежуточного списка. Это экономит память и позволяет начать обработку данных сразу, не дожидаясь завершения всего обхода.
Альтернативный подход — использование стека в итеративной реализации — также совместим с yield, но рекурсивный стиль часто оказывается более естественным для таких задач.
Преимущества итераторов с yield
- Экономия памяти — данные генерируются по требованию, а не хранятся целиком.
- Линейная читаемость — логика перебора выражается в виде последовательного кода, а не через сложные структуры состояний.
- Отложенное выполнение — вычисления происходят только тогда, когда это необходимо, что позволяет избежать ненужной работы.
- Поддержка бесконечных последовательностей — можно создавать итераторы, которые теоретически никогда не заканчиваются, например, генератор случайных чисел или поток событий.
Пример бесконечной последовательности:
public static IEnumerable<long> Fibonacci()
{
long a = 0, b = 1;
while (true)
{
yield return a;
long temp = a + b;
a = b;
b = temp;
}
}
Разбор:
- Фрагмент начинается с
public static IEnumerable<long> Fibonacci()и демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
IEnumerable<T>задаёт контракт перечисления и поддерживает ленивую обработку последовательности.yield returnвыдаёт элементы по одному и сохраняет состояние итератора между шагами. - Поток выполнения ленивый: метод продолжает выполнение только при запросе следующего элемента, а не целиком за один вызов.
Такой итератор можно использовать в связке с LINQ-методами, например, Take(10), чтобы получить первые десять чисел Фибоначчи, не заботясь о том, как именно они генерируются.
Ограничения и особенности
Метод с yield не может содержать параметры ref или out. Он не может быть асинхронным (async) в традиционном смысле (хотя в современных версиях C# появились IAsyncEnumerable<T> и yield return в асинхронных итераторах, но это отдельная тема). Локальные переменные внутри итераторного метода сохраняются между вызовами, что может привести к неожиданному поведению, если их состояние зависит от внешних факторов.
Кроме того, исключения, возникающие внутри итератора, выбрасываются не при вызове метода, а при первом обращении к итератору (например, при входе в цикл foreach). Это важно учитывать при отладке и обработке ошибок.
Связь с LINQ и функциональным стилем
Итераторы на основе yield органично вписываются в функциональный подход к обработке данных. Они позволяют строить цепочки преобразований, где каждый шаг лениво вычисляется. Например, можно создать итератор, фильтрующий, преобразующий и ограничивающий последовательность, не создавая промежуточных коллекций:
public static IEnumerable<string> GetEvenNumberLabels(int max)
{
for (int i = 1; i <= max; i++)
{
if (i % 2 == 0)
yield return $"Число {i}";
}
}
Разбор:
- Фрагмент начинается с
public static IEnumerable<string> GetEvenNumberLabels(int max)и демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
IEnumerable<T>задаёт контракт перечисления и поддерживает ленивую обработку последовательности.yield returnвыдаёт элементы по одному и сохраняет состояние итератора между шагами. - Поток выполнения ленивый: метод продолжает выполнение только при запросе следующего элемента, а не целиком за один вызов.
Этот код легко комбинируется с другими методами, такими как Where, Select, Skip, Take, поскольку он возвращает IEnumerable<T>, совместимый с LINQ.
Сравнение с ручной реализацией IEnumerator<T>
До появления yield разработчикам приходилось вручную реализовывать интерфейсы IEnumerable<T> и IEnumerator<T>, что требовало написания значительного объёма шаблонного кода. Такой подход включал создание отдельного класса-итератора, хранящего текущее состояние (например, индекс в массиве или узел в дереве), а также логику перехода к следующему элементу и проверки завершения.
Пример ручной реализации:
Код ITЗагрузка примера кода…
Разбор:
- Фрагмент начинается с
public class NumberSequence : IEnumerable<int>и демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
IEnumerable<T>задаёт контракт перечисления и поддерживает ленивую обработку последовательности. Лямбда=>задаёт компактную функцию для фильтрации, сортировки, проекции или проверки. Операторnewсоздаёт экземпляры объектов и коллекций, формируя рабочее состояние примера. - Инструкции выполняются последовательно: объявление сущностей, вызов операций и получение конечного результата.
Этот код работает корректно, но он громоздкий, подвержен ошибкам и труден для сопровождения. Любое изменение логики перебора требует модификации нескольких методов и переменных состояния. В отличие от этого, итератор с yield return выражает ту же логику в три строки, оставляя компилятору задачу генерации всего необходимого шаблонного кода.
Таким образом, yield не заменяет IEnumerator<T>, а предоставляет декларативный способ его создания. Под капотом результат идентичен — создаётся скрытый класс, реализующий нужные интерфейсы, с полями для хранения состояния и методами для управления итерацией.
Производительность и накладные расходы
Использование yield не бесплатно. Компилятор генерирует дополнительный класс, который создаётся при каждом вызове итераторного метода. Этот объект занимает память и может быть подвержен сборке мусора, особенно если итерация прерывается досрочно. Однако в большинстве случаев эти накладные расходы незначительны по сравнению с выгодами от ленивой оценки и упрощения кода.
Важно понимать, что каждый вызов итераторного метода создаёт новый экземпляр итератора. Это означает, что повторное использование одного и того же вызова в нескольких циклах приведёт к многократному выполнению логики метода:
var numbers = GetNumbers(); // возвращает IEnumerable<int>
foreach (var n in numbers) { /* ... */ } // первый проход
foreach (var n in numbers) { /* ... */ } // второй проход — метод вызывается заново
Разбор:
- Фрагмент начинается с
var numbers = GetNumbers(); // возвращает IEnumerable<int>и демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
IEnumerable<T>задаёт контракт перечисления и поддерживает ленивую обработку последовательности.foreachперечисляет элементы черезGetEnumerator(),MoveNext()иCurrent. - Выполнение идёт поэлементно: на каждом шаге цикла берётся текущий элемент и применяется нужное действие.
Если логика внутри итератора дорогостоящая (например, чтение из файла или сетевой запрос), такой подход может привести к неэффективности. В таких случаях рекомендуется материализовать последовательность с помощью .ToList() или .ToArray() после первого прохода.
Практические рекомендации
-
Используйте
yieldдля ленивых последовательностей
Когда данные могут быть велики, бесконечны или вычисляются дорого,yieldпозволяет начать обработку немедленно, без предварительного формирования всей коллекции. -
Избегайте побочных эффектов в итераторах
Поскольку итератор может быть вызван несколько раз или не до конца, любые побочные эффекты (например, запись в лог, изменение глобального состояния) должны быть идемпотентными или явно документированы. -
Не используйте
yieldв методах с параметрамиrefилиout
Это ограничение языка C#. Если требуется передача по ссылке, реализуйте итератор вручную или реорганизуйте логику. -
Будьте осторожны с захватом переменных в циклах
При использовании замыканий внутри итератора важно понимать, что переменные цикла захватываются по ссылке. Это может привести к неожиданному поведению, если итератор выполняется асинхронно или отложен. -
Предпочитайте
yield breakявномуreturn
Хотяreturnв итераторном методе автоматически преобразуется вyield break, явное использование последнего делает код более читаемым и сигнализирует о намерении завершить итерацию. -
Тестируйте итераторы как потоки данных
Проверяйте поведение при пустых входных данных, досрочном завершении (breakвforeach) и многократном переборе. Убедитесь, что исключения выбрасываются в правильный момент.
Асинхронные итераторы (IAsyncEnumerable)
Начиная с C# 8.0, появилась поддержка асинхронных итераторов через интерфейс IAsyncEnumerable<T>. Они позволяют использовать yield return внутри методов, помеченных как async, и возвращать элементы по мере их асинхронного получения — например, из базы данных, сети или файловой системы.
Пример:
public async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
using var reader = new StreamReader(File.OpenRead(path));
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
yield return line;
}
}
Разбор:
- Фрагмент начинается с
public async IAsyncEnumerable<string> ReadLinesAsync(string path)и демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
yield returnвыдаёт элементы по одному и сохраняет состояние итератора между шагами. Операторnewсоздаёт экземпляры объектов и коллекций, формируя рабочее состояние примера. - Поток выполнения ленивый: метод продолжает выполнение только при запросе следующего элемента, а не целиком за один вызов.
Такой итератор можно использовать с await foreach, обеспечивая эффективную обработку потоковых данных без блокировки потока выполнения.
Асинхронные итераторы расширяют философию yield на асинхронный мир, сохраняя те же принципы: ленивость, экономию памяти и декларативность.
Основные интерфейсы, лежащие в основе итераторов
IEnumerable<T>
Интерфейс, представляющий коллекцию, по которой можно выполнить перебор. Он содержит единственный метод:
GetEnumerator()— возвращает объект, реализующийIEnumerator<T>.
Этот метод вызывается неявно при использовании конструкцииforeach.
Любой метод, использующий yield return, автоматически возвращает тип, совместимый с IEnumerable<T> (или IEnumerable, если не указан обобщённый параметр).
IEnumerator<T>
Интерфейс, управляющий процессом перебора отдельного элемента за раз. Он предоставляет следующие члены:
Current— свойство типаT, возвращающее текущий элемент последовательности.MoveNext()— метод, перемещающий итератор к следующему элементу. Возвращаетtrue, если следующий элемент существует, иfalse, если перебор завершён.Reset()— метод, сбрасывающий итератор в начальное состояние. На практике редко используется; многие реализации выбрасываютNotSupportedException.Dispose()— метод интерфейсаIDisposable, вызываемый для освобождения ресурсов после завершения перебора (например, при выходе из блокаusingилиforeach).
Когда компилятор обрабатывает метод с yield, он генерирует скрытый класс, реализующий IEnumerator<T> и IDisposable.
Связанные необобщённые интерфейсы
Для совместимости с устаревшим кодом существуют также необобщённые версии:
IEnumerable— содержитGetEnumerator(), возвращающийIEnumerator.IEnumerator— содержитCurrentтипаobject,MoveNext()иReset().
Современный код должен использовать обобщённые версии (IEnumerable<T>, IEnumerator<T>), так как они типобезопасны и избегают упаковки/распаковки значимых типов.
Классы, автоматически генерируемые компилятором
При наличии yield return в методе компилятор создаёт скрытый вложенный класс (обычно с именем вроде <>d__N, где N — номер), который:
- Реализует
IEnumerable<T>и/илиIEnumerator<T>(в зависимости от сигнатуры метода). - Содержит поля для хранения всех локальных переменных, параметров метода и состояния машины (например, метка перехода).
- Содержит метод
MoveNext(), в котором размещается весь исходный код метода, преобразованный в конечный автомат. - Реализует
IDisposable.Dispose(), чтобы корректно завершить итерацию при досрочном выходе.
Этот класс не виден в исходном коде, но доступен через IL Spy, dotPeek или ildasm.
Под капотом: конечный автомат
Многие пишут yield return, не задумываясь, что тела итераторного метода в рантайме нет — компилятор переписывает его в отдельный тип с полем состояния и методом MoveNext().
Для простого метода:
public IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
логика после трансформации (упрощённый псевдокод, не точный IL) выглядит так:
Код ITЗагрузка примера кода…
Пример:
foreach под капотом разворачивается примерно в:
using (var enumerator = collection.GetEnumerator())
{
while (enumerator.MoveNext())
{
var item = enumerator.Current;
// тело цикла
}
}
Именно MoveNext() запускает очередной фрагмент итераторного метода. Пока цикл не запросил следующий элемент, код после предыдущего yield return не выполняется.
Локальные переменные исходного метода (int i в цикле, счётчики, промежуточные ссылки) становятся полями сгенерированного класса — так состояние переживает паузы между вызовами MoveNext(). Параметры метода тоже копируются в поля при создании итератора.
Ленивость — главное следствие
Тело итератора выполняется только при MoveNext(), а не в момент вызова GetNumbers(). Поэтому допустимы бесконечные последовательности:
public static IEnumerable<int> Infinite()
{
int i = 0;
while (true)
yield return i++;
}
// {0, 1, 2, 3, 4} — цикл while (true) не «зависает»:
var firstFive = Infinite().Take(5).ToList();
Take(5) обрывает перечисление после пятого элемента; бесконечный цикл в итераторе так и не дойдёт до "конца". Тот же принцип — в примере Фибоначчи выше: Take(10) ограничивает генерацию.
Практика: поток из БД без материализации всего списка
При чтении через IDataReader (ADO.NET) или аналогичный курсор удобно отдавать объекты по одному:
public static IEnumerable<Order> GetOrders(IDataReader reader)
{
while (reader.Read())
{
yield return new Order
{
Id = reader.GetInt32(0),
// остальные поля...
};
}
}
Каждый Order создаётся в момент следующей итерации foreach, а не все сразу в List<Order>. Обработку можно начать, пока драйвер ещё читает следующую строку. Для асинхронного чтения из сети/БД в современном C# — IAsyncEnumerable<T> (справочник); идея та же, точка приостановки — await + MoveNextAsync().
Аллокации и связь с async/await
Каждый вызов GetNumbers() (или другого итераторного метода) создаёт новый объект сгенерированного класса в куче. В горячем пути с тысячами коротких последовательностей это заметно в профиле памяти. Иногда пишут ручной IEnumerator<T>, struct-enumerator или материализуют результат один раз — если профилировщик показал проблему.
Тот же приём трансформации кода компилятор применяет к async/await: метод превращается в state machine с полями и продолжениями после await. Разница в цели: итератор выдаёт элементы по запросу, async-метод освобождает поток на время I/O. Подробнее — Task и async/await, обзор state machine для async — в асинхронности.
Методы, используемые с итераторами
Хотя yield сам по себе не требует вызова специальных методов, он тесно интегрирован с экосистемой LINQ и стандартными конструкциями языка:
В языке C#
foreach— неявно вызываетGetEnumerator(), затем многократно вызываетMoveNext()и читаетCurrent.using— гарантирует вызовDispose()после завершения перебора, даже при исключении.
В LINQ (System.Linq)
Методы расширения, работающие с IEnumerable<T>, могут принимать результат итератора напрямую:
Where,Select,Take,Skip,First,ToList,ToArrayи другие.
Пример:
var squares = GenerateNumbers().Select(x => x * x).Take(5);
Разбор:
- Фрагмент начинается с
var squares = GenerateNumbers().Select(x => x * x).Take(5);и демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
Select(...)выполняет проекцию и формирует новый результат для каждого элемента. Лямбда=>задаёт компактную функцию для фильтрации, сортировки, проекции или проверки. - LINQ-цепочка выполняется последовательно как конвейер преобразований — фильтрация, проекция, сортировка и материализация.
Здесь GenerateNumbers() — итераторный метод с yield return. Вся цепочка выполняется лениво, пока не вызван ToList() или аналог.
Свойства поведения итераторов с yield
-
Ленивая оценка
Элементы вычисляются только при запросе черезMoveNext(). -
Сохранение состояния
Все локальные переменные сохраняют свои значения между вызовамиMoveNext(). -
Однократное выполнение логики инициализации
Код до первогоyield returnвыполняется при первом вызовеMoveNext(). -
Поддержка вложенных итераторов
Можно использоватьyield returnвнутри цикла, который сам перебирает другойIEnumerable<T>(часто сforeach). -
Автоматическая реализация
IDisposable
Если в итераторе есть блокиtry-finallyили используются ресурсы, компилятор гарантирует их освобождение при завершении итерации.
Асинхронные аналоги (начиная с C# 8.0)
IAsyncEnumerable<T>
Асинхронный аналог IEnumerable<T>. Поддерживает отложенный асинхронный перебор.
GetAsyncEnumerator(CancellationToken)— возвращаетIAsyncEnumerator<T>.
IAsyncEnumerator<T>
Асинхронный аналог IEnumerator<T>.
Current— свойство типаT.MoveNextAsync()— асинхронный метод, возвращающийValueTask<bool>.
Ключевые слова в асинхронных итераторах
yield return— допустим внутриasync IAsyncEnumerable<T>метода.yield break— завершает асинхронную итерацию.
Использование
await foreach (var item in GetItemsAsync())
{
// обработка
}
Разбор:
- Фрагмент начинается с
await foreach (var item in GetItemsAsync())и демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
foreachперечисляет элементы черезGetEnumerator(),MoveNext()иCurrent. - Выполнение идёт поэлементно: на каждом шаге цикла берётся текущий элемент и применяется нужное действие.
Типичные сигнатуры методов с yield
public IEnumerable<T> MethodName(/* параметры */)
{
// yield return ...
// yield break ...
}
public IEnumerator<T> GetEnumerator()
{
// yield return ...
}
Разбор:
- Фрагмент начинается с
public IEnumerable<T> MethodName(/* параметры */)и демонстрирует практический паттерн, который используется в реальном C#-коде. - Ключевые элементы:
IEnumerable<T>задаёт контракт перечисления и поддерживает ленивую обработку последовательности.yield returnвыдаёт элементы по одному и сохраняет состояние итератора между шагами.yield breakзавершает итерацию досрочно и останавливает выдачу элементов. - Поток выполнения ленивый: метод продолжает выполнение только при запросе следующего элемента, а не целиком за один вызов.
Важно: метод не может одновременно:
- быть
asyncи возвращатьIEnumerable<T>(для асинхронности требуетсяIAsyncEnumerable<T>), - содержать
ref/outпараметры, - быть конструктором, деструктором или оператором.