1. Introduzione
Immagina di dover processare un file di log gigantesco con milioni di righe, o di dover generare una sequenza di numeri incredibilmente lunga. Come lo faresti senza generatori?
L'approccio tradizionale sarebbe così:
// Problema: genera tutta la collezione subito in memoria
List<int> GenerateAllNumbersSync(int count)
{
List<int> numbers = new List<int>();
for (int i = 0; i < count; i++)
{
numbers.Add(i);
}
return numbers; // Restituiamo quando è tutto pronto
}
// Uso:
var myNumbers = GenerateAllNumbersSync(1_000_000); // 1 milione di numeri in memoria subito!
foreach (var num in myNumbers) { /* Elaborazione */ }
Quali sono i problemi?
- Consumo di memoria: Se count è molto grande, tutta la collezione viene creata in memoria, il che può portare a un OutOfMemoryException.
- Latenza: L'utente o la parte successiva del programma devono aspettare che tutti i dati siano completamente generati e caricati in memoria.
- Sequenze infinite: Questo approccio semplicemente non funziona se la sequenza è potenzialmente infinita.
Qui entrano in gioco i generatori! Implementano il concetto di valutazione lazy (Lazy Evaluation) e streaming. Invece di generare tutti i dati subito, un generatore produce gli elementi uno alla volta, solo quando servono davvero.
2. Basi dei generatori
In C# i generatori si creano con la parola chiave speciale yield.
Cosa è un generatore?
È un metodo, un blocco get di una proprietà o un operatore che contiene una o più espressioni yield return.
yield return
È il cuore dei generatori. Quando il compilatore incontra yield return:
- L'elemento specificato dopo yield return viene passato al codice chiamante.
- L'esecuzione del metodo-generatore viene sospesa e il suo stato corrente (dove si trova nel ciclo, i valori delle variabili locali) viene salvato.
- Al successivo richiesta di un elemento (ad esempio nella prossima iterazione del foreach), l'esecuzione del metodo riprende da dove era stata sospesa.
Tipo di ritorno: Il metodo-generatore deve restituire IEnumerable<T> o IEnumerator<T>. Il compilatore genera automaticamente tutta la “magia” necessaria.
// Esempio 2.1: Generatore semplice di numeri
IEnumerable<int> GenerateNumbers(int count)
{
Console.WriteLine("Inizio generazione...");
for (int i = 0; i < count; i++)
{
Console.WriteLine($"Genero: {i}");
yield return i; // Sospensione e ritorno dell'elemento
}
Console.WriteLine("Generazione terminata.");
}
// Uso:
// Nota che "Inizio generazione..." apparirà solo alla prima iterazione!
// E "Genero: X" — ad ogni nuova iterazione.
foreach (var num in GenerateNumbers(3))
{
Console.WriteLine($"Ricevuto nel foreach: {num}");
}
yield break
Si usa per terminare prematuramente l'iterazione. Dopo yield break non verranno restituiti altri elementi. Se l'esecuzione raggiunge la fine del metodo, un yield break separato non è necessario.
// Esempio 2.2: Generatore con condizione di uscita
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
int count = 0;
foreach (var item in source)
{
if (count >= n)
{
yield break; // Usciamo dal generatore
}
yield return item;
count++;
}
}
// Uso:
// var fruits = new List<string> { "Apple", "Banana", "Orange", "Grape" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
// Console.WriteLine(fruit); // Stamperà "Apple", "Banana"
// }
3. Macchina a stati
Come funziona “sotto il cofano”? Non c'è magia — solo lavoro intelligente del compilatore.
Quando scrivi un metodo con yield, il compilatore C# lo trasforma in una classe che implementa IEnumerator<T> e IEnumerable<T>. Questa classe generata è la macchina a stati.
- Salvataggio dello stato: la macchina conserva un numero di stato (dove si è fermata) e i valori di tutte le variabili locali al momento della sospensione.
- Iterazione: durante l'iterazione tramite foreach vengono chiamati i metodi MoveNext() e letto la proprietà Current. MoveNext() riprende l'esecuzione fino al prossimo yield return/yield break, e Current restituisce l'elemento corrente.
Di fatto il compilatore implementa per te il pattern Iterator.
4. Applicazioni dei generatori
Esempio: Elaborazione di grandi volumi di dati
Lettura di un file riga per riga senza caricare l'intero file in memoria.
// Simulazione di lettura di un file grande
IEnumerable<string> ReadBigFileLines(string filePath)
{
Console.WriteLine($"Apro il file: {filePath}");
// In un'app reale qui ci sarebbe uno StreamReader
yield return "Riga di dati 1";
yield return "Riga di dati 2";
yield return "Riga di dati 3";
Console.WriteLine("Ho finito di simulare la lettura del file.");
}
// Uso:
Console.WriteLine("Inizio elaborazione.");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
Console.WriteLine($"Riga elaborata: {line}");
if (line.Contains("2")) break; // Possiamo fermarci quando vogliamo
}
Console.WriteLine("Elaborazione completata.");
Nota che il messaggio finale apparirà solo dopo il termine dell'iterazione.
Esempio: Sequenze infinite
IEnumerable<long> FibonacciSequence()
{
long a = 0;
long b = 1;
while (true) // Sequenza potenzialmente infinita
{
yield return a;
long temp = a;
a = b;
b = temp + b;
}
}
// Uso:
int count = 0;
foreach (var num in FibonacciSequence())
{
Console.WriteLine(num);
count++;
if (count >= 10) break; // Dobbiamo limitarci per non bloccarsi
}
Esempio: Pipeline di elaborazione dati
Creare catene di metodi dove ogni passo elabora i dati "al volo".
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;
}
}
// Uso:
foreach (var result in Square(FilterEven(GetNumbers())))
{
Console.WriteLine(result); // 4, 16
}
Questo è molto simile a come funzionano molti operatori di LINQ (per esempio Where, Select, Take, Skip).
5. Generatori asincroni
I generatori sincroni sono fantastici, ma cosa succede se ogni elemento della sequenza richiede un'operazione asincrona (per esempio una richiesta di rete)? Prima di C# 8.0 era difficile da realizzare.
Problema: flussi di dati asincroni
Non possiamo usare await dentro un metodo-generatore sincrono.
// Questo NON COMPILERÀ!
IEnumerable<string> GetStringsAsyncProblem()
{
await Task.Delay(100); // Errore: await può essere solo in un metodo async
yield return "Hello";
}
Soluzione: IAsyncEnumerable<T> e await foreach
- IAsyncEnumerable<T> — l'analogo asincrono di IEnumerable<T>.
- await foreach — sintassi comoda per iterare una sequenza asincrona (internamente chiama MoveNextAsync() e gestisce lo stato asincrono).
async yield return
Ora si può usare yield return dentro un metodo async che ritorna IAsyncEnumerable<T>. Il compilatore costruirà una macchina a stati asincrona.
// Esempio 5.1: Generatore asincrono di numeri
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
Console.WriteLine("Inizio generazione asincrona...");
for (int i = 0; i < 5; i++)
{
await Task.Delay(100); // Simulazione di lavoro asincrono (per es. richiesta di rete)
Console.WriteLine($"Genero asincronicamente: {i}");
yield return i; // Restituiamo l'elemento
}
Console.WriteLine("Generazione asincrona terminata.");
}
// Uso:
async Task ConsumeAsyncNumbers()
{
Console.WriteLine("Inizio elaborazione asincrona...");
await foreach (var number in GenerateNumbersAsync())
{
Console.WriteLine($"Ricevuto asincronicamente: {number}");
}
Console.WriteLine("Elaborazione asincrona completata.");
}
// Avvio:
await ConsumeAsyncNumbers(); // Chiamalo da async Main o contesto simile
IAsyncDisposable e await using (nel contesto dei generatori)
Se il generatore apre una risorsa che viene rilasciata asincronamente (DisposeAsync()), usa await using. Alla fine di un await foreach verrà chiamato automaticamente DisposeAsync() sull'iteratore interno se implementa IAsyncDisposable.
// Esempio 5.2: Lettura file asincrona con await using
// Un vero StreamReader implementa IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
Console.WriteLine($"[Generatore] Apro il file asincronamente: {filePath}");
// await using garantisce la chiamata a DisposeAsync() dopo il blocco
await using var reader = new StreamReader(filePath);
string? line;
while ((line = await reader.ReadLineAsync()) != null) // Lettura asincrona della riga
{
yield return line;
}
Console.WriteLine($"[Generatore] Ho finito di leggere il file: {filePath}");
}
// Uso:
async Task ProcessFileAsync()
{
Console.WriteLine("[Handler] Inizio elaborazione file.");
await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // sostituisci con un percorso reale
{
Console.WriteLine($"[Handler] Riga ricevuta: {line}");
// Qui puoi fare elaborazioni asincrone su ogni riga
await Task.Delay(50);
}
Console.WriteLine("[Handler] Elaborazione file completata.");
}
// Avvio:
await ProcessFileAsync(); // Chiamalo da async Main
Cancelamento dei generatori asincroni: CancellationToken
Aggiungi un CancellationToken ai generatori asincroni così il codice chiamante può cancellare la generazione.
// Esempio 5.3: Generatore asincrono cancellabile
async IAsyncEnumerable<int> GenerateCancelableSequence(
int start, int count,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
for (int i = 0; i < count; i++)
{
token.ThrowIfCancellationRequested(); // Controlliamo il token di cancellazione
await Task.Delay(100, token); // Task.Delay supporta anch'esso la cancellazione
yield return start + i;
}
}
Uso
var cts = new CancellationTokenSource();
Task.Run(async () =>
{
await Task.Delay(300); // Diamo un po' di tempo al generatore
cts.Cancel(); // Cancelliamo!
});
try
{
await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
{
Console.WriteLine($"Ricevuto: {num}");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Generazione cancellata.");
}
6. Limiti e quando usare con cautela
Limiti di yield:
- Non si può usare yield return in blocchi try dove catch o finally contengono anch'essi yield.
- I metodi con yield non possono essere unsafe.
- Non si può usare yield in metodi async void (usa async Task o IAsyncEnumerable<T>).
Performance: Per collezioni molto piccole o di dimensione fissa l'overhead della macchina a stati può essere leggermente superiore al restituire direttamente una List<T>. Ma con grandi quantità di dati il vantaggio della lazy evaluation e dello streaming è di solito molto più importante.
Gestione degli errori: Le eccezioni lanciate dentro un generatore arriveranno correttamente al codice chiamante e possono essere catturate lì come in metodi normali.
GO TO FULL VERSION