Какие существуют подходы и паттерны при работе с yield return (ленивыми последовательностями) в C#?

«Какие существуют подходы и паттерны при работе с yield return (ленивыми последовательностями) в C#?» — вопрос из категории Entity Framework, который задают на 25% собеседований C# Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Ключевое слово yield return в C# используется для создания итераторов и реализации ленивого вычисления (lazy evaluation). Вот основные подходы и паттерны для их эффективного использования:

1. Ленивая генерация последовательностей

Элементы вычисляются и возвращаются по одному в момент перечисления, а не все сразу. Это экономит память, особенно для больших или потенциально бесконечных последовательностей.

public static IEnumerable<int> GenerateFibonacci(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;
    }
}
// Использование: foreach (var num in GenerateFibonacci(10)) { ... }
// Числа вычисляются по мере необходимости в цикле.

2. Создание конвейеров обработки данных (Pipelines)

Можно строить цепочки методов с yield return, где каждый метод выполняет одно преобразование. Данные "протекают" через конвейер по одному элементу.

public static IEnumerable<int> GetNumbers() {
    for (int i = 1; i <= 5; i++) yield return i;
}

public static IEnumerable<int> Square(this IEnumerable<int> source) {
    foreach (var num in source) yield return num * num;
}

public static IEnumerable<string> Format(this IEnumerable<int> source) {
    foreach (var num in source) yield return $"Value: {num}";
}

// Конвейер: Генерация -> Возведение в квадрат -> Форматирование
var result = GetNumbers().Square().Format();
// Каждый элемент проходит всю цепочку перед обработкой следующего.

3. Фильтрация и отложенная фильтрация

Итераторы идеально подходят для реализации фильтров, которые применяются только во время перечисления.

public static IEnumerable<T> WhereLazy<T>(this IEnumerable<T> source, Predicate<T> predicate) {
    foreach (var item in source) {
        if (predicate(item)) {
            yield return item;
        }
    }
}
// Фильтрация произойдёт только при перечислении результата.
var evenNumbers = GetNumbers().WhereLazy(n => n % 2 == 0);

4. Объединение нескольких последовательностей

С помощью yield return можно легко объединять данные из разных источников.

public static IEnumerable<T> Merge<T>(params IEnumerable<T>[] sequences) {
    foreach (var seq in sequences) {
        foreach (var item in seq) {
            yield return item;
        }
    }
}

5. Бесконечные последовательности

Итераторы позволяют представлять потенциально бесконечные потоки данных.

public static IEnumerable<int> RandomNumbers(Random rng) {
    while (true) {
        yield return rng.Next();
    }
}
// Важно использовать с методами, которые ограничивают перечисление, например, .Take()
var tenRandomNumbers = RandomNumbers(new Random()).Take(10);

Критически важные замечания

  • Повторное выполнение: Итератор выполняет свой код заново при каждом новом перечислении. Если генерация или обработка ресурсоёмки, результат стоит материализовать (.ToList(), .ToArray()).
  • Сторонние эффекты: Изменение состояния во время перечисления может привести к неожиданному поведению, если последовательность перечисляется несколько раз.
  • Нельзя использовать в try-catch: yield return нельзя помещать внутрь блока try с catch. Это ограничение компилятора.

Итог: yield return — мощный инструмент для работы с последовательностями, который позволяет писать эффективный по памяти и выполнению код, особенно полезный для обработки потоков данных, больших коллекций и построения конвейеров.