JavaRush /Курси /C# SELF /Синхронні та асинхронні генератори в C# (

Синхронні та асинхронні генератори в C# ( yield)

C# SELF
Рівень 62 , Лекція 1
Відкрита

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) { /* Обробка */ }

Які тут проблеми?

  1. Споживання памʼяті: Якщо count дуже великий, вся колекція створюється в памʼяті, що може призвести до OutOfMemoryException.
  2. Затримка: Користувач або наступна частина програми змушені чекати, доки всі дані повністю згенеруються й завантажаться в памʼять.
  3. Нескінченні послідовності: Такий підхід просто не працює, якщо послідовність потенційно нескінченна.

На допомогу приходять генератори! Вони реалізують концепції ледачих обчислень (Lazy Evaluation) і потокової обробки (Streaming). Замість того, щоб генерувати всі дані одразу, генератор повертає елементи по одному — лише тоді, коли вони справді потрібні.

2. Основи генераторів

У C# генератори створюються за допомогою спеціального ключового слова yield.

Що таке генератор?

Це метод, блок get властивості або оператор, що містить один або кілька виразів yield return.

yield return

Це серце генераторів. Коли компілятор натрапляє на yield return:

  1. Елемент, вказаний після yield return, передається коду, який викликає.
  2. Виконання методу-генератора призупиняється, а його поточний стан (де він зупинився в циклі, значення локальних змінних) зберігається.
  3. Під час наступного запиту елемента (наприклад, у наступній ітерації циклу 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>. Однак на великих даних вигода від «ледачості» та потокової обробки зазвичай значно важливіша.

Обробка помилок: Винятки, кинуті всередині генератора, коректно долетять до коду, який викликає, і можуть бути перехоплені там так само, як у звичайних методах.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ