1. Введение
Представьте, что вам нужно обработать гигантский файл логов, в котором миллионы строк, или сгенерировать невероятно длинную последовательность чисел. Как бы вы это сделали без генераторов?
Традиционный подход выглядел бы так:
// Проблема: генерирует всю коллекцию сразу в памяти
List<int> GenerateAllNumbersSync(int count)
{
List<int> numbers = new List<int>();
for (int i = 0; i < count; i++)
{
numbers.Add(i);
}
return numbers; // Возвращаем, когда все готово
}
// Использование:
var myNumbers = GenerateAllNumbersSync(1_000_000); // 1 миллион чисел сразу в памяти!
foreach (var num in myNumbers) { /* Обработка */ }
Какие тут проблемы?
- Потребление памяти: Если count очень большой, вся коллекция создаётся в памяти, что может привести к OutOfMemoryException.
- Задержка: Пользователь или следующая часть программы вынуждена ждать, пока все данные будут полностью сгенерированы и загружены в память.
- Бесконечные последовательности: Такой подход просто не работает, если последовательность потенциально бесконечна.
На помощь приходят генераторы! Они реализуют концепцию ленивых вычислений (Lazy Evaluation) и потоковой обработки (Streaming). Вместо того чтобы генерировать все данные сразу, генератор производит элементы по одному, только тогда, когда они действительно нужны.
2. Основы генераторов
В C# генераторы создаются с помощью специального ключевого слова yield.
Что такое Генератор?
Это метод, блок get свойства или оператор, который содержит одно или несколько выражений yield return.
yield return
Это сердце генераторов. Когда компилятор встречает yield return:
- Элемент, указанный после yield return, передаётся вызывающему коду.
- Выполнение метода-генератора приостанавливается, а его текущее состояние (где он находится в цикле, значения локальных переменных) сохраняется.
- При следующем запросе элемента (например, в следующей итерации цикла foreach), выполнение метода возобновляется с того места, где оно было приостановлено.
Возвращаемый тип: Метод-генератор должен возвращать IEnumerable<T> или IEnumerator<T>. Компилятор сам сгенерирует всю необходимую «магию».
// Пример 2.1: Простой генератор чисел
IEnumerable<int> GenerateNumbers(int count)
{
Console.WriteLine("Начало генерации...");
for (int i = 0; i < count; i++)
{
Console.WriteLine($"Генерирую: {i}");
yield return i; // Приостановка и возврат элемента
}
Console.WriteLine("Генерация завершена.");
}
// Использование:
// Обратите внимание, что "Начало генерации..." появится только при первой итерации!
// А "Генерирую: X" — при каждой новой итерации.
foreach (var num in GenerateNumbers(3))
{
Console.WriteLine($"Получено в foreach: {num}");
}
yield break
Используется для досрочного завершения итерации. После yield break больше элементов возвращено не будет. Если выполнение доходит до конца метода, отдельный yield break не нужен.
// Пример 2.2: Генератор с условием выхода
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
int count = 0;
foreach (var item in source)
{
if (count >= n)
{
yield break; // Выходим из генератора
}
yield return item;
count++;
}
}
// Использование:
// var fruits = new List<string> { "Apple", "Banana", "Orange", "Grape" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
// Console.WriteLine(fruit); // Выведет "Apple", "Banana"
// }
3. Машина состояний
Как же это работает «под капотом»? Здесь нет магии — только умная работа компилятора.
Когда вы пишете метод с yield, компилятор C# преобразует его в класс, который реализует IEnumerator<T> и IEnumerable<T>. Этот сгенерированный класс — машина состояний.
- Сохранение состояния: машина храни́т номер состояния (где остановились) и значения всех локальных переменных на момент приостановки.
- Итерация: при переборе через foreach вызываются методы MoveNext() и читается свойство Current. MoveNext() возобновляет выполнение до следующего yield return/yield break, а Current возвращает текущий элемент.
Фактически компилятор реализует за вас паттерн Итератор (Iterator Pattern).
4. Применение генераторов
Пример: Обработка больших объёмов данных
Чтение файла построчно без загрузки всего файла в память.
// Имитация чтения большого файла
IEnumerable<string> ReadBigFileLines(string filePath)
{
Console.WriteLine($"Открываю файл: {filePath}");
// В реальном приложении здесь будет StreamReader
yield return "Строка данных 1";
yield return "Строка данных 2";
yield return "Строка данных 3";
Console.WriteLine("Закончил имитировать чтение файла.");
}
// Использование:
Console.WriteLine("Начало обработки.");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
Console.WriteLine($"Обработана строка: {line}");
if (line.Contains("2")) break; // Мы можем остановиться, когда захотим
}
Console.WriteLine("Обработка завершена.");
Обратите внимание, что завершающее сообщение появится только после завершения итерации.
Пример: Бесконечные последовательности
IEnumerable<long> FibonacciSequence()
{
long a = 0;
long b = 1;
while (true) // Потенциально бесконечная последовательность
{
yield return a;
long temp = a;
a = b;
b = temp + b;
}
}
// Использование:
int count = 0;
foreach (var num in FibonacciSequence())
{
Console.WriteLine(num);
count++;
if (count >= 10) break; // Мы должны ограничить, чтобы не зависнуть
}
Пример: Конвейеры обработки данных
Создание цепочек методов, где каждый шаг обрабатывает данные «на лету».
IEnumerable<int> GetNumbers()
{
yield return 1; yield return 2; yield return 3; yield return 4; yield return 5;
}
IEnumerable<int> FilterEven(IEnumerable<int> source)
{
foreach (var num in source)
{
if (num % 2 == 0) yield return num;
}
}
IEnumerable<int> Square(IEnumerable<int> source)
{
foreach (var num in source)
{
yield return num * num;
}
}
// Использование:
foreach (var result in Square(FilterEven(GetNumbers())))
{
Console.WriteLine(result); // 4, 16
}
Это очень похоже на то, как работают многие операторы LINQ (например, Where, Select, Take, Skip).
5. Асинхронные генераторы
Синхронные генераторы великолепны, но что, если каждый элемент последовательности требует асинхронной операции (например, сетевой запрос)? До C# 8.0 это было трудно реализовать.
Проблема: Асинхронные потоки данных
Мы не можем использовать await внутри синхронного метода-генератора.
// Это НЕ СКОМПИЛИРУЕТСЯ!
IEnumerable<string> GetStringsAsyncProblem()
{
await Task.Delay(100); // Ошибка: await может быть только в async методе
yield return "Hello";
}
Решение: IAsyncEnumerable<T> и await foreach
- IAsyncEnumerable<T> — асинхронный аналог IEnumerable<T>.
- await foreach — удобный синтаксис для перебора асинхронной последовательности (внутри вызывает MoveNextAsync() и обрабатывает асинхронное состояние).
async yield return
Теперь можно использовать yield return внутри async-метода, возвращающего IAsyncEnumerable<T>. Компилятор построит асинхронную машину состояний.
// Пример 5.1: Асинхронный генератор чисел
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
Console.WriteLine("Начало асинхронной генерации...");
for (int i = 0; i < 5; i++)
{
await Task.Delay(100); // Имитация асинхронной работы (например, сетевой запрос)
Console.WriteLine($"Асинхронно генерирую: {i}");
yield return i; // Возвращаем элемент
}
Console.WriteLine("Асинхронная генерация завершена.");
}
// Использование:
async Task ConsumeAsyncNumbers()
{
Console.WriteLine("Начало асинхронной обработки...");
await foreach (var number in GenerateNumbersAsync())
{
Console.WriteLine($"Асинхронно получено: {number}");
}
Console.WriteLine("Асинхронная обработка завершена.");
}
// Запуск:
await ConsumeAsyncNumbers(); // Вызовите это из async Main или подобного контекста
IAsyncDisposable и await using (в контексте генераторов)
Если генератор открывает ресурс, который освобождается асинхронно (DisposeAsync()), используйте await using. При завершении await foreach автоматически вызовет DisposeAsync() у внутреннего итератора, если он реализует IAsyncDisposable.
// Пример 5.2: Асинхронное чтение файла с await using
// Реальный StreamReader реализует IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
Console.WriteLine($"[Генератор] Открываю файл асинхронно: {filePath}");
// await using гарантирует вызов DisposeAsync() после выхода из блока
await using var reader = new StreamReader(filePath);
string? line;
while ((line = await reader.ReadLineAsync()) != null) // Асинхронное чтение строки
{
yield return line;
}
Console.WriteLine($"[Генератор] Закончил чтение файла: {filePath}");
}
// Использование:
async Task ProcessFileAsync()
{
Console.WriteLine("[Обработчик] Начало обработки файла.");
await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // замените на реальный путь
{
Console.WriteLine($"[Обработчик] Получена строка: {line}");
// Здесь можно выполнять асинхронную обработку каждой строки
await Task.Delay(50);
}
Console.WriteLine("[Обработчик] Обработка файла завершена.");
}
// Запуск:
await ProcessFileAsync(); // Вызовите это из async Main
Отмена асинхронных генераторов: CancellationToken
Добавляйте CancellationToken в асинхронные генераторы, чтобы вызывающий код мог отменять генерацию.
// Пример 5.3: Асинхронный генератор с отменой
async IAsyncEnumerable<int> GenerateCancelableSequence(
int start, int count,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
for (int i = 0; i < count; i++)
{
token.ThrowIfCancellationRequested(); // Проверяем токен отмены
await Task.Delay(100, token); // Task.Delay тоже поддерживает отмену
yield return start + i;
}
}
Использование
var cts = new CancellationTokenSource();
Task.Run(async () =>
{
await Task.Delay(300); // Дадим генератору поработать немного
cts.Cancel(); // Отменяем!
});
try
{
await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
{
Console.WriteLine($"Получено: {num}");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Генерация отменена.");
}
6. Ограничения и когда использовать осторожно
Ограничения yield:
- Нельзя использовать yield return в try-блоках, где catch или finally тоже содержат yield.
- Методы с yield не могут быть unsafe.
- Нельзя использовать yield в async void-методах (используйте async Task или IAsyncEnumerable<T>).
Производительность: Для очень маленьких или фиксированных коллекций накладные расходы машины состояний могут быть чуточку выше, чем прямой возврат List<T>. Но при больших данных выигрыш от ленивости и потоковой обработки обычно значительно важнее.
Обработка ошибок: Исключения, выброшенные внутри генератора, будут корректно долетать до вызывающего кода и могут быть перехвачены там так же, как в обычных методах.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ