Каждый цикл foreach и каждый запрос LINQ работают через один небольшой интерфейс: IEnumerable<T>. Понимание его объясняет, почему LINQ ленив, почему некоторые последовательности можно прочитать только один раз и как yield return позволяет писать собственные последовательности обычным циклом.
Что такое IEnumerable
У IEnumerable<T> один метод, GetEnumerator(). Возвращаемый им перечислитель имеет MoveNext() (перейти дальше и сообщить, есть ли элемент) и Current (сам элемент). foreach это сокращённая запись управления таким перечислителем:
Вывод:
red green blue
red green blue
Перечислитель начинает перед первым элементом, поэтому первый MoveNext() переходит на него. Когда MoveNext() возвращает false, последовательность закончилась. Блок using освобождает перечислитель, что важно для итераторов, которые держат ресурсы, например открытые файлы.
IEnumerable, ICollection и List
Интерфейсы коллекций образуют лестницу. Каждая ступень обещает больше:
| Тип | Что добавляет | Типичное применение |
|---|---|---|
IEnumerable<T> | только foreach | Параметры, которые вы только перебираете; результаты LINQ; ленивые последовательности |
IReadOnlyCollection<T> | Count | Возвращаемые значения, которые вызывающий код не должен менять |
ICollection<T> | Count, Add, Remove, Contains | Код, который добавляет в коллекцию любого вида |
IReadOnlyList<T> | Доступ по индексу [i] | Результаты только для чтения с позициями |
IList<T> | Доступ по индексу, Insert, RemoveAt | Код, который редактирует по позиции |
List<T> | Конкретный класс, реализующий всё это | Хранение |
Метод, который только перебирает входные данные, должен принимать IEnumerable<T>, чтобы вызывающий код мог передать массив, список, множество или запрос LINQ без преобразования. Метод, который возвращает данные, обычно должен возвращать что-то, говорящее «это уже в памяти», например List<T> или IReadOnlyList<T>, потому что возвращаемый IEnumerable<T> может оказаться запросом, который выполняет работу при каждом чтении.
Вывод:
30
15
4.0
30
Итератор с yield return
Чтобы создать собственную последовательность, напишите метод, возвращающий IEnumerable<T>, и используйте yield return для каждого элемента. Компилятор превращает метод в класс, который реализует перечислитель за вас.
Вывод:
0, 2, 4, 6, 8, 10
Mon Tue Wed Thu Fri
В EvenNumbers нет никакого списка. Каждый yield return передаёт одно значение вызывающему коду и приостанавливает метод, сохраняя его локальные переменные (здесь i) живыми, пока вызывающий код не запросит следующее значение.
Ленивость: следите за порядком выполнения
Поскольку метод приостанавливается, его тело и цикл вызывающего кода выполняются по очереди. Вывод с обеих сторон показывает чередование:
Вывод:
Called Numbers(), nothing printed yet
start
yielding 1
Loop got 1
resumed after 1
yielding 2
Loop got 2
resumed after 2
yielding 3
Loop got 3
resumed after 3
end
Вызов Numbers() не напечатал «start»: он только создал перечисляемый объект. Тело начало выполняться при первом MoveNext() внутри foreach, дошло до первого yield return и остановилось. Каждый следующий шаг продолжался со строки после yield. Where и Select из LINQ ведут себя так же (среда выполнения реализует их написанными вручную классами-перечислителями ради скорости, но эффект тот же), поэтому запросы LINQ отложенные.
yield break
yield break досрочно завершает последовательность, это версия return для итераторов:
Вывод:
Subject: Invoice
From: shop@example.com
Смешивать return value; и yield return в одном методе нельзя. Как только в методе есть yield, он становится итератором, и yield break это единственный способ выйти из него раньше.
Бесконечные последовательности
Поскольку вычисляются только запрошенные элементы, итератор может описывать последовательность без конца. Сколько взять, решает потребитель:
Вывод:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34
1597
2026-09-28 Mon
2026-10-05 Mon
2026-10-12 Mon
while (true) здесь безопасен, потому что Take и First перестают запрашивать, получив нужное. А вот вызов ToList() или Count() для бесконечной последовательности никогда не вернётся, как и перечисление OrderBy по ней, потому что сортировке сначала нужны все элементы.
Ловушки ленивых последовательностей
Проверки аргументов выполняются поздно. Всё тело итератора откладывается, включая проверку в его начале. Исправление: разделить метод. Обычный метод проверяет аргументы и возвращает результат закрытого итератора.
Вывод:
LazyRange(-1) returned without an error
...and threw only when enumerated
EagerRange(-1) threw at the call
Каждое перечисление запускает метод заново. Итератор ничего не кэширует. Два цикла по Numbers() дважды печатают «start», дважды перечитывают файл или дважды вызывают веб-сервис. IDE отмечают это как «possible multiple enumeration of IEnumerable». Вызовите ToList() один раз, когда элементы нужны больше одного раза.
finally выполняется, когда потребитель останавливается. Если вызывающий код досрочно выходит из foreach, перечислитель освобождается и выполняется блок finally в итераторе; так итератор, читающий файл, его закрывает:
Вывод:
open sensor
10
20
30
close sensor
done
Несколько правил, которые компилятор применяет к итераторам: yield return не может находиться внутри блока try с catch (try/finally допустим), внутри лямбды или анонимного метода, а также в методе с параметрами ref или out.
Асинхронные потоки
Для последовательностей, элементы которых приходят асинхронно (страницы из API, строки из базы данных), C# 8 и новее (с .NET Core 3.0 или новее) объединяют итераторы с await через IAsyncEnumerable<T>:
static async IAsyncEnumerable<string> FetchPages()
{
for (int page = 1; page <= 3; page++)
{
await Task.Delay(100); // simulate a network call
yield return $"page {page}";
}
}
await foreach (string p in FetchPages())
{
Console.WriteLine(p);
}
Потребитель использует await foreach, и каждый элемент ожидается по мере его появления.
Частые ошибки
- Возврат
IEnumerable<T>из запроса и многократное чтение. Каждое чтение выполняет его заново; возвращайтеList<T>. - Проверка аргументов в итераторе. Проверка откладывается; разделите на обёртку и итератор.
- Вызов
Count()илиToList()у бесконечного итератора. Он никогда не завершится; сначалаTake. - Ожидание, что у параметра
IEnumerable<T>естьCountили[i]. ИспользуйтеCount()из LINQ (который может перебрать всё) или принимайтеIReadOnlyList<T>.
Часто задаваемые вопросы
Что такое IEnumerable в C#?
IEnumerable<T> это интерфейс для всего, что можно перебрать через foreach. У него один метод, GetEnumerator(), который возвращает IEnumerator<T> с MoveNext() и Current. Его реализуют массивы, списки, словари, множества, запросы LINQ и методы-итераторы.
Чем IEnumerable отличается от List в C#?
IEnumerable<T> обещает только возможность перебора; у него нет свойства Count, индексатора и Add, и он может вычисляться лениво при каждом цикле. List<T> это конкретная коллекция в памяти, у которой всё это есть. Принимайте IEnumerable<T> в параметрах, когда только перебираете, и возвращайте List<T> или IReadOnlyList<T>, когда вызывающему коду нужно обращаться по индексу или считать.
Что делает yield return в C#?
yield return value; внутри метода, возвращающего IEnumerable<T>, передаёт вызывающему коду один элемент и приостанавливает метод в этой точке. Когда вызывающий код в следующий раз запросит элемент, метод продолжит со следующей строки. Компилятор переписывает метод в конечный автомат, поэтому вы пишете цикл, а получаете ленивую последовательность.
Что такое yield break в C#?
yield break; завершает последовательность: foreach вызывающего кода нормально заканчивается после уже выданных элементов. Это аналог return для итераторов, и его используют, чтобы остановиться раньше, например при достижении лимита или на пустом входе.
Почему мой метод-итератор не выбрасывает исключение при вызове?
Тело метода-итератора не выполняется, пока последовательность не начнут перечислять, поэтому проверки аргументов в его начале срабатывают при первом MoveNext, а не при вызове. Чтобы проверять сразу, поместите проверки в обычный открытый метод, который затем вызывает закрытый метод-итератор с операторами yield.