1. Introdução
Imagine que você precisa processar um arquivo de logs gigante, com milhões de linhas, ou gerar uma sequência de números incrivelmente longa. Como você faria isso sem geradores?
O approach tradicional seria algo assim:
// Problema: gera toda a coleção de uma vez na memória
List<int> GenerateAllNumbersSync(int count)
{
List<int> numbers = new List<int>();
for (int i = 0; i < count; i++)
{
numbers.Add(i);
}
return numbers; // Retorna quando tudo estiver pronto
}
// Uso:
var myNumbers = GenerateAllNumbersSync(1_000_000); // 1 milhão de números na memória de uma vez!
foreach (var num in myNumbers) { /* Processamento */ }
Quais são os problemas aqui?
- Consumo de memória: Se count for muito grande, toda a coleção é criada na memória, o que pode levar a OutOfMemoryException.
- Latency (atraso): O usuário ou a próxima parte do programa precisa esperar até que todos os dados sejam totalmente gerados e carregados na memória.
- Sequências infinitas: Essa abordagem simplesmente não funciona se a sequência for potencialmente infinita.
É aí que entram os geradores! Eles implementam o conceito de avaliação preguiçosa (Lazy Evaluation) e processamento em streaming. Em vez de gerar todos os dados de uma vez, o gerador produz elementos um por um, somente quando realmente necessários.
2. Fundamentos dos geradores
Em C# os geradores são criados usando a palavra-chave especial yield.
O que é um Gerador?
É um método, o bloco get de uma propriedade ou uma expressão que contém uma ou mais instruções yield return.
yield return
É o coração dos geradores. Quando o compilador encontra yield return:
- O elemento especificado depois de yield return é entregue ao código chamador.
- A execução do método-gerador é pausada, e seu estado atual (onde está no loop, valores das variáveis locais) é salvo.
- Na próxima solicitação de elemento (por exemplo, na próxima iteração do foreach), a execução do método é retomada do ponto onde foi pausada.
Tipo de retorno: O método-gerador deve retornar IEnumerable<T> ou IEnumerator<T>. O compilador vai gerar toda a "mágica" necessária.
// Exemplo 2.1: Gerador simples de números
IEnumerable<int> GenerateNumbers(int count)
{
Console.WriteLine("Início da geração...");
for (int i = 0; i < count; i++)
{
Console.WriteLine($"Gerando: {i}");
yield return i; // Pausa e retorna o elemento
}
Console.WriteLine("Geração finalizada.");
}
// Uso:
// Repare que "Início da geração..." aparece só na primeira iteração!
// E "Gerando: X" aparece a cada nova iteração.
foreach (var num in GenerateNumbers(3))
{
Console.WriteLine($"Recebido no foreach: {num}");
}
yield break
Usado para terminar a iteração antecipadamente. Após yield break nenhum elemento será retornado. Se a execução chegar ao fim do método, um yield break explícito não é necessário.
// Exemplo 2.2: Gerador com condição de saída
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
int count = 0;
foreach (var item in source)
{
if (count >= n)
{
yield break; // Saímos do gerador
}
yield return item;
count++;
}
}
// Uso:
// var fruits = new List<string> { "Apple", "Banana", "Orange", "Grape" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
// Console.WriteLine(fruit); // Vai imprimir "Apple", "Banana"
// }
3. Máquina de estados
Como isso funciona "por baixo do capô"? Não tem mágica — só trabalho esperto do compilador.
Quando você escreve um método com yield, o compilador C# o transforma em uma classe que implementa IEnumerator<T> e IEnumerable<T>. Essa classe gerada é a máquina de estados.
- Persistência de estado: a máquina guarda um número de estado (onde parou) e os valores de todas as variáveis locais no momento da pausa.
- Iteração: ao iterar com foreach são chamados métodos MoveNext() e lida a propriedade Current. MoveNext() retoma a execução até o próximo yield return/yield break, e Current retorna o elemento atual.
Na prática o compilador implementa pra você o padrão Iterator.
4. Aplicações dos geradores
Exemplo: Processamento de grandes volumes de dados
Ler um arquivo linha a linha sem carregar tudo na memória.
// Simulação de leitura de um arquivo grande
IEnumerable<string> ReadBigFileLines(string filePath)
{
Console.WriteLine($"Abrindo arquivo: {filePath}");
// Em uma aplicação real aqui teria um StreamReader
yield return "Linha de dado 1";
yield return "Linha de dado 2";
yield return "Linha de dado 3";
Console.WriteLine("Terminei de simular a leitura do arquivo.");
}
// Uso:
Console.WriteLine("Início do processamento.");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
Console.WriteLine($"Linha processada: {line}");
if (line.Contains("2")) break; // Podemos parar quando quisermos
}
Console.WriteLine("Processamento finalizado.");
Repare que a mensagem final aparece só depois de terminar a iteração.
Exemplo: Sequências infinitas
IEnumerable<long> FibonacciSequence()
{
long a = 0;
long b = 1;
while (true) // Sequência potencialmente 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; // Precisamos limitar pra não travar
}
Exemplo: Pipelines de processamento
Criar cadeias de métodos onde cada etapa processa os dados "on the fly".
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
}
Isso é bem parecido com como muitos operadores do LINQ funcionam (por exemplo, Where, Select, Take, Skip).
5. Geradores assíncronos
Geradores síncronos são ótimos, mas e se cada elemento da sequência exigir uma operação assíncrona (por exemplo, uma chamada de rede)? Antes do C# 8.0 isso era difícil de implementar.
Problema: Fluxos de dados assíncronos
Não podemos usar await dentro de um método-gerador síncrono.
// Isso NÃO VAI COMPILAR!
IEnumerable<string> GetStringsAsyncProblem()
{
await Task.Delay(100); // Erro: await só pode em método async
yield return "Hello";
}
Solução: IAsyncEnumerable<T> e await foreach
- IAsyncEnumerable<T> — análogo assíncrono de IEnumerable<T>.
- await foreach — sintaxe conveniente para iterar uma sequência assíncrona (internamente chama MoveNextAsync() e lida com o estado assíncrono).
async yield return
Agora você pode usar yield return dentro de um método async que retorna IAsyncEnumerable<T>. O compilador vai construir uma máquina de estados assíncrona.
// Exemplo 5.1: Gerador assíncrono de números
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
Console.WriteLine("Início da geração assíncrona...");
for (int i = 0; i < 5; i++)
{
await Task.Delay(100); // Simula trabalho assíncrono (ex.: chamada de rede)
Console.WriteLine($"Gerando assíncronamente: {i}");
yield return i; // Retorna o elemento
}
Console.WriteLine("Geração assíncrona finalizada.");
}
// Uso:
async Task ConsumeAsyncNumbers()
{
Console.WriteLine("Início do processamento assíncrono...");
await foreach (var number in GenerateNumbersAsync())
{
Console.WriteLine($"Recebido assíncronamente: {number}");
}
Console.WriteLine("Processamento assíncrono finalizado.");
}
// Execução:
await ConsumeAsyncNumbers(); // Chame isso a partir de um async Main ou contexto similar
IAsyncDisposable e await using (no contexto de geradores)
Se o gerador abre um recurso que é liberado de forma assíncrona (DisposeAsync()), use await using. Ao terminar um await foreach, o iterador interno terá DisposeAsync() chamado automaticamente se implementar IAsyncDisposable.
// Exemplo 5.2: Leitura assíncrona de arquivo com await using
// StreamReader real implementa IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
Console.WriteLine($"[Gerador] Abrindo arquivo assincronamente: {filePath}");
// await using garante chamada a DisposeAsync() após sair do bloco
await using var reader = new StreamReader(filePath);
string? line;
while ((line = await reader.ReadLineAsync()) != null) // Leitura assíncrona de linha
{
yield return line;
}
Console.WriteLine($"[Gerador] Terminei de ler o arquivo: {filePath}");
}
// Uso:
async Task ProcessFileAsync()
{
Console.WriteLine("[Handler] Início do processamento de arquivo.");
await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // substitua por um caminho real
{
Console.WriteLine($"[Handler] Linha recebida: {line}");
// Aqui dá pra fazer processamento assíncrono de cada linha
await Task.Delay(50);
}
Console.WriteLine("[Handler] Processamento do arquivo finalizado.");
}
// Execução:
await ProcessFileAsync(); // Chame isso a partir de async Main
Cancelamento de geradores assíncronos: CancellationToken
Adicione um CancellationToken aos geradores assíncronos para que o código chamador possa cancelar a geração.
// Exemplo 5.3: Gerador assíncrono com cancelamento
async IAsyncEnumerable<int> GenerateCancelableSequence(
int start, int count,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
for (int i = 0; i < count; i++)
{
token.ThrowIfCancellationRequested(); // Checa o token de cancelamento
await Task.Delay(100, token); // Task.Delay também suporta cancelamento
yield return start + i;
}
}
Uso
var cts = new CancellationTokenSource();
Task.Run(async () =>
{
await Task.Delay(300); // Deixa o gerador trabalhar um pouco
cts.Cancel(); // Cancelamos!
});
try
{
await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
{
Console.WriteLine($"Recebido: {num}");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Geração cancelada.");
}
6. Limitações e quando usar com cuidado
Limitações do yield:
- Não é permitido usar yield return em blocos try onde catch ou finally também contenham yield.
- Métodos com yield não podem ser unsafe.
- Não é possível usar yield em métodos async void (use async Task ou IAsyncEnumerable<T>).
Performance: Para coleções muito pequenas ou fixas, a sobrecarga da máquina de estados pode ser um pouco maior do que retornar diretamente um List<T>. Mas em cenários com muitos dados, o ganho da lazy evaluation e do streaming geralmente compensa muito mais.
Tratamento de erros: Exceções lançadas dentro do gerador vão alcançar corretamente o código chamador e podem ser capturadas lá como em métodos normais.
GO TO FULL VERSION