Зачем вам yield в C#? Секрет ленивых коллекций
Представьте: вы пишете метод, который должен вернуть список чисел от 1 до миллиона. Первое, что приходит в голову — создать List<int>, заполнить его и вернуть. Но что, если вызывающий код хочет только первые 10 элементов? Вы всё равно потратили время на создание миллиона объектов, заняли память — и всё зря.
Здесь на сцену выходит ключевое слово yield в C#. Это не просто синтаксический сахар, а мощный инструмент для создания ленивых (lazy) последовательностей. В этой статье мы разберём yield return и yield break на реальных примерах, поймём, как они работают под капотом, и научимся применять их в повседневной разработке.
Этот туториал основан на реальном вопросе с Stack Overflow, который собрал тысячи просмотров. Мы адаптировали его для образовательного контента, добавив пошаговые примеры кода C# и полезные практические советы.
Что такое yield в C#? Простыми словами
yield — это контекстное ключевое слово в C#, которое сообщает компилятору: «Этот метод — итератор». Когда вы пишете yield return, метод не выполняет все сразу. Вместо этого он возвращает элементы по одному, когда их запрашивает вызывающий код. Это называется ленивым выполнением.
В отличие от обычного return, который завершает метод, yield return ставит метод на паузу, запоминает его состояние и возвращает текущий элемент. При следующем вызове метод продолжает работу с того места, где остановился.
Синтаксис: yield return и yield break
В C# есть две формы yield:
- yield return — возвращает следующий элемент последовательности.
- yield break — немедленно завершает итерацию (как
breakв цикле).
Метод, использующий yield, должен возвращать один из этих типов:
IEnumerable<T>IEnumerableIAsyncEnumerable<T>(начиная с C# 8.0)IEnumerator<T>илиIEnumerator
Первый пример: от простого к сложному
Давайте напишем простейший итератор, который генерирует числа от 1 до 5:
public static IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
yield return 4;
yield return 5;
}
// Использование:
foreach (int number in GetNumbers())
{
Console.WriteLine(number);
}
Вывод:
1
2
3
4
5
На первый взгляд, это похоже на обычный список. Но разница огромна: каждый элемент генерируется только тогда, когда его запрашивает foreach. Никакого выделения памяти под весь список!
Как это работает под капотом?
Компилятор C# превращает метод с yield в полноценный конечный автомат (state machine). Он создаёт класс, который реализует IEnumerable<T> и IEnumerator<T>. Этот класс хранит:
- Текущее состояние (на какой строке метода мы остановились).
- Локальные переменные метода.
- Ссылку на текущий объект (
this), если метод нестатический.
Каждый раз, когда вызывается MoveNext() (а это делает foreach), конечный автомат возобновляет выполнение с места последней остановки.
Реальный пример: чтение большого файла
Представьте, что вам нужно обработать файл с логами размером 10 ГБ. Если вы прочитаете его целиком в память, программа упадёт с OutOfMemoryException. С yield вы можете читать файл построчно и обрабатывать каждую строку на лету:
public static IEnumerable<string> ReadLines(string filePath)
{
using (var reader = new StreamReader(filePath))
{
string line;
while ((line = reader.ReadLine()) != null)
{
yield return line; // Возвращаем строку, не загружая весь файл
}
}
}
// Использование:
foreach (string logLine in ReadLines("huge_log.txt"))
{
if (logLine.Contains("ERROR"))
{
Console.WriteLine(logLine);
}
}
Обратите внимание на using — он корректно закроет файл, когда итерация завершится или будет прервана.
yield break: когда нужно остановиться
Иногда вам нужно досрочно завершить итерацию. Для этого используется yield break. Например, если мы хотим генерировать числа Фибоначчи, но остановиться, когда значение превысит 1000:
public static IEnumerable<int> Fibonacci(int maxValue)
{
int a = 0, b = 1;
while (true)
{
if (a > maxValue)
yield break; // Выходим из итератора
yield return a; // Возвращаем текущее число
int temp = a;
a = b;
b = temp + b;
}
}
// Использование:
foreach (int fib in Fibonacci(100))
{
Console.Write(fib + " "); // Вывод: 0 1 1 2 3 5 8 13 21 34 55 89
}
Без yield break цикл while(true) был бы бесконечным. А так итерация завершается, как только условие выполнено.
Производительность: yield vs список
Давайте сравним два подхода на примере генерации первых 10 чисел Фибоначчи:
Подход 1: Обычный список
public static List<int> GetFibonacciList(int count)
{
var result = new List<int>();
int a = 0, b = 1;
for (int i = 0; i < count; i++)
{
result.Add(a);
int temp = a;
a = b;
b = temp + b;
}
return result;
}
Подход 2: С yield
public static IEnumerable<int> GetFibonacciYield(int count)
{
int a = 0, b = 1;
for (int i = 0; i < count; i++)
{
yield return a;
int temp = a;
a = b;
b = temp + b;
}
}
Разница в производительности: версия со списком сразу выделяет память под 10 int (40 байт + служебные данные). Версия с yield не выделяет память вообще — каждый элемент вычисляется на лету. Если вызывающий код прервёт итерацию после 3-го элемента, yield-версия сэкономит 70% работы.
Асинхронные итераторы: yield с async/await (C# 8.0+)
Начиная с C# 8.0, появились асинхронные итераторы