CodeGym /Kursy /C# SELF /Synchroniczne i asynchroniczne generatory w C# (

Synchroniczne i asynchroniczne generatory w C# ( yield)

C# SELF
Poziom 62 , Lekcja 1
Dostępny

1. Wprowadzenie

Wyobraź sobie, że musisz przetworzyć gigantyczny plik logów zawierający miliony linii, albo wygenerować niesamowicie długą sekwencję liczb. Jak byś to zrobił bez generatorów?

Tradycyjne podejście wyglądałoby tak:


// Problem: generuje całą kolekcję naraz w pamięci
List<int> GenerateAllNumbersSync(int count)
{
    List<int> numbers = new List<int>();
    for (int i = 0; i < count; i++)
    {
        numbers.Add(i);
    }
    return numbers; // Zwracamy, kiedy wszystko jest gotowe
}

// Użycie:
var myNumbers = GenerateAllNumbersSync(1_000_000); // 1 milion liczb naraz w pamięci!
foreach (var num in myNumbers) { /* Przetwarzanie */ }

Jakie tu są problemy?

  1. Zużycie pamięci: Jeśli count jest bardzo duże, cała kolekcja tworzy się w pamięci, co może prowadzić do OutOfMemoryException.
  2. Opóźnienie: Użytkownik lub kolejna część programu musi czekać, aż wszystkie dane zostaną wygenerowane i załadowane do pamięci.
  3. Nieskończone sekwencje: Takie podejście nie działa, jeśli sekwencja jest potencjalnie nieskończona.

Na ratunek przychodzą generatory! Implementują one koncepcję leniwych obliczeń (Lazy Evaluation) i przetwarzania strumieniowego (Streaming). Zamiast generować wszystkie dane naraz, generator produkuje elementy po jednym, tylko wtedy, gdy są naprawdę potrzebne.

2. Podstawy generatorów

W C# generatory tworzy się za pomocą specjalnego słowa kluczowego yield.

Co to jest generator?

To metoda, blok get właściwości albo operator, który zawiera jedno lub więcej wyrażeń yield return.

yield return

To serce generatorów. Gdy kompilator napotka yield return:

  1. Element podany po yield return jest przekazywany do wywołującego kodu.
  2. Wykonanie metody-generatora zostaje wstrzymane, a jej bieżący stan (gdzie jest w pętli, wartości zmiennych lokalnych) zostaje zapisany.
  3. Przy następnym żądaniu elementu (np. w kolejnej iteracji pętli foreach), wykonanie metody wznawia się od miejsca, w którym zostało wstrzymane.

Typ zwracany: Metoda-generator musi zwracać IEnumerable<T> lub IEnumerator<T>. Kompilator sam wygeneruje całą niezbędną „magikę”.


// Przykład 2.1: Prosty generator liczb
IEnumerable<int> GenerateNumbers(int count)
{
    Console.WriteLine("Beginning generation...");
    for (int i = 0; i < count; i++)
    {
        Console.WriteLine($"Generating: {i}");
        yield return i; // Wstrzymanie i zwrócenie elementu
    }
    Console.WriteLine("Generation completed.");
}

// Użycie:
// Zauważ, że "Beginning generation..." pojawi się dopiero przy pierwszej iteracji!
// A "Generating: X" — przy każdej nowej iteracji.
foreach (var num in GenerateNumbers(3))
{
    Console.WriteLine($"Received foreach: {num}");
}

yield break

Używane do wcześniejszego zakończenia iteracji. Po yield break nie zostaną zwrócone żadne kolejne elementy. Jeśli wykonanie dochodzi do końca metody, osobny yield break nie jest potrzebny.


// Przykład 2.2: Generator z warunkiem wyjścia
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
    int count = 0;
    foreach (var item in source)
    {
        if (count >= n)
        {
            yield break; // Wychodzimy z generatora
        }
        yield return item;
        count++;
    }
}

// Użycie:
// var fruits = new List<string> { "Yabloko", "Banana", "Apelsin", "Vinohrad" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
//     Console.WriteLine(fruit); // Wypisze "Yabloko", "Banana"
// }

3. Maszyna stanów

Jak to działa „pod maską”? Tu nie ma magii — tylko sprytna robota kompilatora.

Gdy piszesz metodę z yield, kompilator C# przekształca ją w klasę, która implementuje IEnumerator<T> i IEnumerable<T>. Ta wygenerowana klasa to maszyna stanów.

  • Zachowanie stanu: maszyna przechowuje numer stanu (gdzie się zatrzymała) i wartości wszystkich zmiennych lokalnych w momencie wstrzymania.
  • Iteracja: podczas przeglądania przez foreach wywoływane są metody MoveNext() i odczytywane jest właściwość Current. MoveNext() wznawia wykonanie do następnego yield return/yield break, a Current zwraca bieżący element.

W praktyce kompilator implementuje za Ciebie wzorzec Iterator (Iterator Pattern).

4. Zastosowania generatorów

Przykład: Przetwarzanie dużych ilości danych

Odczyt pliku linia po linii bez ładowania całego pliku do pamięci.


// Symulacja czytania dużego pliku
IEnumerable<string> ReadBigFileLines(string filePath)
{
    Console.WriteLine($"Otwieram plik: {filePath}");
    // W realnej aplikacji tutaj byłby StreamReader
    yield return "String data 1";
    yield return "String data 2";
    yield return "String data 3";
    Console.WriteLine("Skonczylem symulowac czytanie pliku.");
}

// Użycie:
Console.WriteLine("Poczatek przetwarzania.");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
    Console.WriteLine($"Przetworzona linia: {line}");
    if (line.Contains("2")) break; // Możemy przerwać, kiedy chcemy
}
Console.WriteLine("Przetwarzanie zakonczone.");

Zauważ, że końcowy komunikat pojawi się tylko po zakończeniu iteracji.

Przykład: Nieskończone sekwencje


IEnumerable<long> FibonacciSequence()
{
    long a = 0;
    long b = 1;
    while (true) // Potencjalnie nieskończona sekwencja
    {
        yield return a;
        long temp = a;
        a = b;
        b = temp + b;
    }
}

// Użycie:
int count = 0;
foreach (var num in FibonacciSequence())
{
    Console.WriteLine(num);
    count++;
    if (count >= 10) break; // Musimy ograniczyć, żeby się nie zawiesić
}

Przykład: Potoki przetwarzania danych

Tworzenie łańcuchów metod, gdzie każdy krok przetwarza dane „w locie”.


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;
    }
}

// Użycie:
foreach (var result in Square(FilterEven(GetNumbers())))
{
    Console.WriteLine(result); // 4, 16
}

To bardzo przypomina działanie wielu operatorów LINQ (np. Where, Select, Take, Skip).

5. Asynchroniczne generatory

Synchroniczne generatory są świetne, ale co jeśli każdy element sekwencji wymaga asynchronicznej operacji (np. zapytanie sieciowe)? Do C# 8.0 było to trudne do zrealizowania.

Problem: Asynchroniczne strumienie danych

Nie możemy użyć await wewnątrz synchronicznej metody-generatora.


// To NIE SKOMPALUJE SIĘ!
IEnumerable<string> GetStringsAsyncProblem()
{
    await Task.Delay(100); // Błąd: await może być tylko w async metodzie
    yield return "Hello";
}

Rozwiązanie: IAsyncEnumerable<T> i await foreach

  • IAsyncEnumerable<T> — asynchroniczny odpowiednik IEnumerable<T>.
  • await foreach — wygodna składnia do iteracji asynchronicznej sekwencji (wewnętrznie wywołuje MoveNextAsync() i obsługuje asynchroniczny stan).

async yield return

Teraz można używać yield return wewnątrz async-metody zwracającej IAsyncEnumerable<T>. Kompilator zbuduje asynchroniczną maszynę stanów.


// Przykład 5.1: Asynchroniczny generator liczb
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
    Console.WriteLine("Beginning asynchronous generation...");
    for (int i = 0; i < 5; i++)
    {
        await Task.Delay(100); // Symulacja asynchronicznej pracy (np. zapytanie sieciowe)
        Console.WriteLine($"Asynchronously generating: {i}");
        yield return i; // Zwracamy element
    }
    Console.WriteLine("Asynchroniczna generation completed.");
}

// Użycie:
async Task ConsumeAsyncNumbers()
{
    Console.WriteLine("Beginning asynchronicznej obrobki...");
    await foreach (var number in GenerateNumbersAsync())
    {
        Console.WriteLine($"Asynchronously received: {number}");
    }
    Console.WriteLine("Asynchroniczna obrobka completed.");
}

// Uruchomienie:
await ConsumeAsyncNumbers(); // Wywołaj to z async Main lub podobnego kontekstu

IAsyncDisposable i await using (w kontekście generatorów)

Jeśli generator otwiera zasób, który jest zwalniany asynchronicznie (DisposeAsync()), użyj await using. Przy zakończeniu await foreach automatycznie wywoła DisposeAsync() dla wewnętrznego iteratora, jeśli on implementuje IAsyncDisposable.


// Przykład 5.2: Asynchroniczne czytanie pliku z await using
// Prawdziwy StreamReader implementuje IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
    Console.WriteLine($"[Generator] Otwieram plik asynchronicznie: {filePath}");
    // await using zapewnia wywołanie DisposeAsync() po wyjściu z bloku
    await using var reader = new StreamReader(filePath); 
    
    string? line;
    while ((line = await reader.ReadLineAsync()) != null) // Asynchroniczny odczyt linii
    {
        yield return line;
    }
    Console.WriteLine($"[Generator] Skonczylem czytanie pliku: {filePath}");
}

// Użycie:
async Task ProcessFileAsync()
{
    Console.WriteLine("[Handler] Początek przetwarzania pliku.");
    await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // zamień na rzeczywistą ścieżkę
    {
        Console.WriteLine($"[Handler] Otrzymana linia: {line}");
        // Tutaj można wykonywać asynchroniczne przetwarzanie każdej linii
        await Task.Delay(50); 
    }
    Console.WriteLine("[Handler] Przetwarzanie pliku zakończone.");
}

// Uruchomienie:
await ProcessFileAsync(); // Wywołaj to z async Main

Anulowanie asynchronicznych generatorów: CancellationToken

Dodawaj CancellationToken do asynchronicznych generatorów, żeby wywołujący kod mógł anulować generowanie.


// Przykład 5.3: Asynchroniczny generator z anulowaniem
async IAsyncEnumerable<int> GenerateCancelableSequence(
    int start, int count, 
    [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
    for (int i = 0; i < count; i++)
    {
        token.ThrowIfCancellationRequested(); // Sprawdzamy token anulowania
        await Task.Delay(100, token); // Task.Delay też wspiera anulowanie
        yield return start + i;
    }
}

Użycie


var cts = new CancellationTokenSource();
Task.Run(async () =>
{
    await Task.Delay(300); // Dajemy generatorowi chwilę pracy
    cts.Cancel(); // Anulujemy!
});

try
{
    await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
    {
        Console.WriteLine($"Otrzymano: {num}");
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine("Generacja anulowana.");
}

6. Ograniczenia i kiedy używać ostrożnie

Ograniczenia yield:

  • Nie można używać yield return w try-blokach, gdzie catch lub finally również zawierają yield.
  • Metody z yield nie mogą być unsafe.
  • Nie można używać yield w async void-metodach (używaj async Task lub IAsyncEnumerable<T>).

Wydajność: Dla bardzo małych lub stałych kolekcji narzut maszyny stanów może być nieco większy niż bezpośredni zwrot List<T>. Ale przy dużych danych zyski z leniwości i przetwarzania strumieniowego zwykle są dużo ważniejsze.

Obsługa błędów: Wyjątki wyrzucane wewnątrz generatora będą poprawnie propagowane do wywołującego kodu i można je tam przechwycić tak samo jak w zwykłych metodach.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION