1. Introducción
Imagina que tienes que procesar un archivo de logs gigantesco con millones de líneas, o generar una secuencia de números increíblemente larga. ¿Cómo harías eso sin generadores?
El enfoque tradicional sería algo así:
// Problema: genera toda la colección en memoria de golpe
List<int> GenerateAllNumbersSync(int count)
{
List<int> numbers = new List<int>();
for (int i = 0; i < count; i++)
{
numbers.Add(i);
}
return numbers; // Devolvemos cuando todo está listo
}
// Uso:
var myNumbers = GenerateAllNumbersSync(1_000_000); // ¡1 millón de números en memoria de golpe!
foreach (var num in myNumbers) { /* Procesamiento */ }
¿Qué problemas hay aquí?
- Consumo de memoria: Si count es muy grande, toda la colección se crea en memoria, lo que puede llevar a OutOfMemoryException.
- Latencia: El usuario o la siguiente parte del programa tiene que esperar hasta que todos los datos estén generados y cargados en memoria.
- Secuencias infinitas: Este enfoque simplemente no funciona si la secuencia es potencialmente infinita.
Aquí entran los generadores al rescate. Implementan el concepto de cálculo perezoso (Lazy Evaluation) y procesamiento en streaming. En vez de generar todo de golpe, el generador produce elementos uno a la vez, solo cuando realmente se necesitan.
2. Fundamentos de los generadores
En C# los generadores se crean con la palabra clave especial yield.
¿Qué es un generador?
Es un método, el bloque get de una propiedad o una sentencia que contiene una o varias expresiones yield return.
yield return
Es el corazón de los generadores. Cuando el compilador encuentra yield return:
- El elemento indicado después de yield return se pasa al código llamante.
- La ejecución del método-generador se pausa, y su estado actual (dónde está en el bucle, valores de variables locales) se guarda.
- En la siguiente petición del elemento (por ejemplo, en la siguiente iteración del foreach), la ejecución del método se reanuda desde el punto donde se pausó.
Tipo de retorno: El método-generador debe devolver IEnumerable<T> o IEnumerator<T>. El compilador generará toda la «magia» necesaria automáticamente.
// Ejemplo 2.1: Generador simple de números
IEnumerable<int> GenerateNumbers(int count)
{
Console.WriteLine("Inicio de la generación...");
for (int i = 0; i < count; i++)
{
Console.WriteLine($"Generando: {i}");
yield return i; // Pausa y devuelve el elemento
}
Console.WriteLine("Generación completada.");
}
// Uso:
// Observa que "Inicio de la generación..." aparecerá solo en la primera iteración!
// Y "Generando: X" — en cada nueva iteración.
foreach (var num in GenerateNumbers(3))
{
Console.WriteLine($"Obtenido en foreach: {num}");
}
yield break
Se usa para terminar la iteración anticipadamente. Después de yield break no se devolverán más elementos. Si la ejecución llega al final del método, no hace falta un yield break separado.
// Ejemplo 2.2: Generador con condición de salida
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
int count = 0;
foreach (var item in source)
{
if (count >= n)
{
yield break; // Salimos del generador
}
yield return item;
count++;
}
}
// Uso:
// var fruits = new List<string> { "Apple", "Banana", "Orange", "Grape" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
// Console.WriteLine(fruit); // Imprimirá "Apple", "Banana"
// }
3. Máquina de estados
¿Cómo funciona esto «por debajo del capó»? No hay magia — solo el trabajo inteligente del compilador.
Cuando escribes un método con yield, el compilador de C# lo transforma en una clase que implementa IEnumerator<T> y IEnumerable<T>. Esa clase generada es la máquina de estados.
- Guardado de estado: la máquina guarda el número de estado (dónde se quedó) y los valores de todas las variables locales en el momento de la pausa.
- Iteración: al recorrer con foreach se invocan los métodos MoveNext() y se lee la propiedad Current. MoveNext() reanuda la ejecución hasta el siguiente yield return/yield break, y Current devuelve el elemento actual.
De hecho el compilador implementa por ti el patrón Iterator.
4. Aplicaciones de los generadores
Ejemplo: Procesamiento de grandes volúmenes de datos
Leer un archivo línea por línea sin cargar todo el archivo en memoria.
// Simulación de lectura de un archivo grande
IEnumerable<string> ReadBigFileLines(string filePath)
{
Console.WriteLine($"Abriendo archivo: {filePath}");
// En una aplicación real aquí habría un StreamReader
yield return "Línea de datos 1";
yield return "Línea de datos 2";
yield return "Línea de datos 3";
Console.WriteLine("He terminado de simular la lectura del archivo.");
}
// Uso:
Console.WriteLine("Inicio del procesamiento.");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
Console.WriteLine($"Línea procesada: {line}");
if (line.Contains("2")) break; // Podemos detenernos cuando queramos
}
Console.WriteLine("Procesamiento completado.");
Fíjate que el mensaje final aparece solo después de finalizar la iteración.
Ejemplo: Secuencias infinitas
IEnumerable<long> FibonacciSequence()
{
long a = 0;
long b = 1;
while (true) // Secuencia 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; // Debemos limitar para no quedarnos colgados
}
Ejemplo: Pipelines de procesamiento de datos
Crear cadenas de métodos donde cada paso procesa datos "en vuelo".
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
}
Esto se parece mucho a cómo funcionan muchos operadores de LINQ (por ejemplo, Where, Select, Take, Skip).
5. Generadores asíncronos
Los generadores síncronos son geniales, pero ¿y si cada elemento de la secuencia requiere una operación asíncrona (por ejemplo, una petición de red)? Antes de C# 8.0 eso era difícil de lograr.
Problema: flujos de datos asíncronos
No podemos usar await dentro de un método-generador síncrono.
// ¡ESTO NO COMPILARÁ!
IEnumerable<string> GetStringsAsyncProblem()
{
await Task.Delay(100); // Error: await solo en métodos async
yield return "Hello";
}
Solución: IAsyncEnumerable<T> y await foreach
- IAsyncEnumerable<T> — análogo asíncrono de IEnumerable<T>.
- await foreach — sintaxis cómoda para iterar una secuencia asíncrona (internamente llama a MoveNextAsync() y maneja el estado asíncrono).
async yield return
Ahora se puede usar yield return dentro de un método marcado con async que devuelve IAsyncEnumerable<T>. El compilador construirá una máquina de estados asíncrona.
// Ejemplo 5.1: Generador asíncrono de números
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
Console.WriteLine("Inicio de la generación asíncrona...");
for (int i = 0; i < 5; i++)
{
await Task.Delay(100); // Simula trabajo asíncrono (por ejemplo, petición de red)
Console.WriteLine($"Generando asíncronamente: {i}");
yield return i; // Devolvemos el elemento
}
Console.WriteLine("Generación asíncrona completada.");
}
// Uso:
async Task ConsumeAsyncNumbers()
{
Console.WriteLine("Inicio del procesamiento asíncrono...");
await foreach (var number in GenerateNumbersAsync())
{
Console.WriteLine($"Obtenido asíncronamente: {number}");
}
Console.WriteLine("Procesamiento asíncrono completado.");
}
// Arranque:
await ConsumeAsyncNumbers(); // Llámalo desde async Main o contexto similar
IAsyncDisposable y await using (en el contexto de generadores)
Si el generador abre un recurso que se libera de forma asíncrona (DisposeAsync()), usa await using. Al terminar un await foreach, se llamará automáticamente a DisposeAsync() del iterador interno si implementa IAsyncDisposable.
// Ejemplo 5.2: Lectura asíncrona de archivo con await using
// El StreamReader real implementa IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
Console.WriteLine($"[Generador] Abriendo archivo asíncronamente: {filePath}");
// await using garantiza la llamada a DisposeAsync() al salir del bloque
await using var reader = new StreamReader(filePath);
string? line;
while ((line = await reader.ReadLineAsync()) != null) // Lectura asíncrona de línea
{
yield return line;
}
Console.WriteLine($"[Generador] He terminado de leer el archivo: {filePath}");
}
// Uso:
async Task ProcessFileAsync()
{
Console.WriteLine("[Procesador] Inicio del procesamiento de archivo.");
await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // reemplaza por una ruta real
{
Console.WriteLine($"[Procesador] Línea recibida: {line}");
// Aquí se puede realizar procesamiento asíncrono por línea
await Task.Delay(50);
}
Console.WriteLine("[Procesador] Procesamiento de archivo completado.");
}
// Arranque:
await ProcessFileAsync(); // Llámalo desde async Main
Cancelación de generadores asíncronos: CancellationToken
Añade CancellationToken a los generadores asíncronos para que el código llamante pueda cancelar la generación.
// Ejemplo 5.3: Generador asíncrono con cancelación
async IAsyncEnumerable<int> GenerateCancelableSequence(
int start, int count,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
for (int i = 0; i < count; i++)
{
token.ThrowIfCancellationRequested(); // Comprobamos el token de cancelación
await Task.Delay(100, token); // Task.Delay también soporta cancelación
yield return start + i;
}
}
Uso
var cts = new CancellationTokenSource();
Task.Run(async () =>
{
await Task.Delay(300); // Dejamos que el generador trabaje un poco
cts.Cancel(); // ¡Cancelamos!
});
try
{
await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
{
Console.WriteLine($"Obtenido: {num}");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Generación cancelada.");
}
6. Limitaciones y cuándo usar con precaución
Limitaciones de yield:
- No se puede usar yield return dentro de bloques try donde catch o finally también contienen yield.
- Los métodos con yield no pueden ser unsafe.
- No se puede usar yield en métodos async void (usa async Task o IAsyncEnumerable<T>).
Rendimiento: Para colecciones muy pequeñas o fijas, la sobrecarga de la máquina de estados puede ser ligeramente mayor que devolver directamente un List<T>. Pero para datos grandes la ventaja de la pereza y el streaming suele ser mucho más importante.
Manejo de errores: Las excepciones lanzadas dentro del generador llegarán correctamente al código llamante y pueden ser capturadas allí igual que en métodos normales.
GO TO FULL VERSION