Menu

IEnumerable y yield return en C#: secuencias perezosas explicadas

IEnumerable<T> es la interfaz que hay detrás de foreach y LINQ, y yield return es la forma más sencilla de implementarla. Aprende cómo se relaciona IEnumerable con ICollection y List, cómo se ejecuta paso a paso un método iterador, yield break, las secuencias infinitas y las trampas de la evaluación perezosa.

Esta página incluye editores ejecutables: edita, ejecuta y ve el resultado al instante.

Todo bucle foreach y toda consulta LINQ funcionan a través de una interfaz pequeña: IEnumerable<T>. Entenderla explica por qué LINQ es perezoso, por qué algunas secuencias solo pueden leerse una vez y cómo yield return te permite escribir tus propias secuencias con un bucle normal.

Qué es IEnumerable

IEnumerable<T> tiene un solo método, GetEnumerator(). El enumerador que devuelve tiene MoveNext() (avanzar e indicar si hay un elemento) y Current (el elemento). Un foreach es una forma abreviada de manejar ese enumerador:

Salida:

red green blue 
red green blue 

El enumerador empieza antes del primer elemento, así que el primer MoveNext() se coloca sobre él. Cuando MoveNext() devuelve false, la secuencia ha terminado. El bloque using libera el enumerador, algo que importa en los iteradores que tienen recursos como archivos abiertos.

IEnumerable frente a ICollection frente a List

Las interfaces de colección forman una escalera. Cada peldaño promete más:

TipoAñadeUso típico
IEnumerable<T>solo foreachParámetros que solo recorres; resultados de LINQ; secuencias perezosas
IReadOnlyCollection<T>CountValores de retorno que quien llama no debe modificar
ICollection<T>Count, Add, Remove, ContainsCódigo que añade a una colección de cualquier tipo
IReadOnlyList<T>Acceso por índice [i]Resultados de solo lectura con posiciones
IList<T>Acceso por índice, Insert, RemoveAtCódigo que edita por posición
List<T>La clase concreta que implementa todas ellasAlmacenamiento

Un método que solo recorre su entrada debería aceptar IEnumerable<T>, para que quien llama pueda pasar un array, una lista, un conjunto o una consulta LINQ sin convertir nada. Un método que devuelve datos normalmente debería devolver algo que diga "esto ya está en memoria", como List<T> o IReadOnlyList<T>, porque un valor de retorno IEnumerable<T> podría ser una consulta que hace trabajo cada vez que se lee.

Salida:

30
15
4.0
30

Escribir un iterador con yield return

Para producir tu propia secuencia, escribe un método que devuelva IEnumerable<T> y usa yield return para cada elemento. El compilador convierte el método en una clase que implementa el enumerador por ti.

Salida:

0, 2, 4, 6, 8, 10
Mon Tue Wed Thu Fri 

No hay ninguna lista en EvenNumbers. Cada yield return entrega un valor a quien llama y pausa el método, manteniendo vivas sus variables locales (i aquí) hasta que quien llama pide el siguiente valor.

Pereza: fíjate en el orden de ejecución

Como el método se pausa, su cuerpo y el bucle de quien llama se turnan. Imprimir desde los dos lados muestra cómo se intercalan:

Salida:

Called Numbers(), nothing printed yet
  start
  yielding 1
Loop got 1
  resumed after 1
  yielding 2
Loop got 2
  resumed after 2
  yielding 3
Loop got 3
  resumed after 3
  end

Llamar a Numbers() no imprimió "start": solo creó el enumerable. El cuerpo empezó en el primer MoveNext() dentro del foreach, se ejecutó hasta el primer yield return y se detuvo. Cada paso posterior se reanudó en la línea que sigue al yield. Where y Select de LINQ se comportan igual (el runtime los implementa con clases de enumerador escritas a mano por velocidad, pero el efecto es idéntico), y por eso las consultas LINQ son diferidas.

yield break

yield break termina la secuencia antes de tiempo; es la versión de return para iteradores:

Salida:

Subject: Invoice
From: shop@example.com

No puedes mezclar return value; y yield return en un mismo método. En cuanto un método contiene yield, es un iterador, y yield break es la única forma de salir antes.

Secuencias infinitas

Como solo se calculan los elementos que se piden, un iterador puede describir una secuencia sin fin. Quien la consume decide cuánto toma:

Salida:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34
1597
2026-09-28 Mon
2026-10-05 Mon
2026-10-12 Mon

while (true) es seguro aquí porque Take y First dejan de pedir cuando ya tienen lo que necesitan. En cambio, llamar a ToList() o Count() sobre una secuencia infinita nunca termina, y tampoco enumerar un OrderBy sobre ella, porque ordenar necesita antes todos los elementos.

Trampas de las secuencias perezosas

Las comprobaciones de argumentos se ejecutan tarde. Todo el cuerpo de un iterador se difiere, incluida la validación del principio. La solución es dividir el método: un método normal comprueba los argumentos y devuelve el resultado de un iterador privado.

Salida:

LazyRange(-1) returned without an error
...and threw only when enumerated
EagerRange(-1) threw at the call

Cada enumeración vuelve a ejecutar el método. Un iterador no guarda nada en caché. Recorrer dos veces Numbers() imprime "start" dos veces, vuelve a leer el archivo dos veces o llama dos veces al servicio web. Los IDE lo señalan como "possible multiple enumeration of IEnumerable". Llama una vez a ToList() cuando necesites los elementos más de una vez.

finally se ejecuta cuando el consumidor se detiene. Si quien llama sale antes del foreach, el enumerador se libera y se ejecuta cualquier bloque finally del iterador, que es como un iterador que lee un archivo lo cierra:

Salida:

open sensor
10
20
30
close sensor
done

Algunas reglas que el compilador impone a los iteradores: yield return no puede aparecer dentro de un bloque try que tenga un catch (un try/finally no da problemas), dentro de una lambda o un método anónimo, ni en un método con parámetros ref u out.

Flujos async

Para secuencias cuyos elementos llegan de forma asíncrona (páginas de una API, filas de una base de datos), C# 8 y posteriores (con .NET Core 3.0 o posterior) combinan los iteradores con await mediante IAsyncEnumerable<T>:

static async IAsyncEnumerable<string> FetchPages()
{
    for (int page = 1; page <= 3; page++)
    {
        await Task.Delay(100);           // simulate a network call
        yield return $"page {page}";
    }
}

await foreach (string p in FetchPages())
{
    Console.WriteLine(p);
}

Quien consume usa await foreach, y cada elemento se espera a medida que se produce.

Errores comunes

  • Devolver IEnumerable<T> desde una consulta y leerlo muchas veces. Cada lectura la vuelve a ejecutar; devuelve una List<T> en su lugar.
  • Validar argumentos en un iterador. La comprobación se difiere; divídelo en un método envoltorio y un iterador.
  • Llamar a Count() o ToList() sobre un iterador infinito. Nunca termina; usa antes Take.
  • Esperar que un parámetro IEnumerable<T> tenga Count o [i]. Usa el Count() de LINQ (que puede enumerar) o acepta IReadOnlyList<T>.

Preguntas frecuentes

¿Qué es IEnumerable en C#?

IEnumerable<T> es la interfaz de todo lo que se puede recorrer con foreach. Tiene un solo método, GetEnumerator(), que devuelve un IEnumerator<T> con MoveNext() y Current. Los arrays, las listas, los diccionarios, los conjuntos, las consultas LINQ y los métodos iteradores la implementan todos.

¿Qué diferencia hay entre IEnumerable y List en C#?

IEnumerable<T> solo promete que puedes iterar; no tiene propiedad Count, ni indexador, ni Add, y puede calcularse de forma perezosa cada vez que haces el bucle. List<T> es una colección concreta guardada en memoria con todo eso. Acepta IEnumerable<T> en los parámetros cuando solo recorres, y devuelve una List<T> o un IReadOnlyList<T> cuando quien llama necesita indexar o contar.

¿Qué hace yield return en C#?

yield return value; dentro de un método que devuelve IEnumerable<T> entrega un elemento a quien llama y pausa el método en ese punto. La siguiente vez que quien llama pide un elemento, el método se reanuda en la línea siguiente. El compilador reescribe el método como una máquina de estados, así que escribes un bucle y obtienes una secuencia perezosa.

¿Qué es yield break en C#?

yield break; termina la secuencia: el foreach de quien llama acaba con normalidad después de los elementos ya producidos. Es el equivalente de return en un iterador y se usa para parar antes, por ejemplo cuando se alcanza un límite o la entrada está vacía.

¿Por qué mi método iterador no lanza una excepción cuando lo llamo?

El cuerpo de un método iterador no se ejecuta hasta que se enumera la secuencia, así que las comprobaciones de argumentos del principio se ejecutan en el primer MoveNext, no en la llamada. Para validar de inmediato, pon las comprobaciones en un método público normal que después llame a un método iterador privado con las sentencias yield.

Coddy programming languages illustration

Aprende a programar con Coddy

COMENZAR