C# yield: пошаговый туториал по ленивым коллекциям

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Зачем вам 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>
  • IEnumerable
  • IAsyncEnumerable<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, появились асинхронные итераторы

Блоги

Рекомендаций по книгам