wandres.dev
ITERADORES · la pereza y su poder

La pereza: nada ocurre hasta que se consume

Los iteradores son perezosos: los adaptadores como `map` y `filter` no ejecutan nada, solo construyen un nuevo iterador que envuelve al anterior. El trabajo solo sucede cuando un consumidor tira de los valores, uno a uno, de atrás hacia adelante. Por eso encadenar es gratis y por eso los iteradores infinitos son útiles.

⏱ 16 min

Escribes v.iter().map(carisimo).filter(raro) y no pasa absolutamente nada. Ni una llamada a carisimo, ni una comprobación de raro. Has descrito una transformación, pero no la has ejecutado: los iteradores de Rust son perezosos (lazy). Un adaptador no computa; construye —envuelve el iterador anterior en uno nuevo y se aparta—. El trabajo real duerme hasta que algo, al final de la cadena, empieza a tirar de los valores. Esta pereza no es un defecto ni una optimización menor: es la propiedad que hace que encadenar diez adaptadores no cueste diez pasadas, y que un iterador infinito sea una herramienta cotidiana en vez de un cuelgue seguro.

🎯 Al terminar esta lección sabrás
  • Distinguir un adaptador (perezoso, devuelve un iterador) de un consumidor (ejecuta).
  • Comprobar que encadenar adaptadores no realiza ningún trabajo por sí solo.
  • Entender el modelo de tracción: el consumidor pide, el valor fluye elemento a elemento.
  • Aprovechar la pereza para trabajar con iteradores infinitos mediante take.

Encadenar construye, no ejecuta

Un adaptador como map no aplica la función a nada. Devuelve un valor de tipo Map<I, F>: una estructura que guarda el iterador de origen I y la clausura F, sin llamarla ni una vez. filter devuelve un Filter, take un Take, y así con todos. Encadenar adaptadores es apilar envoltorios, una operación puramente estructural:

let it = (1..=3).map(|x| {
    println!("procesando {x}");   // efecto visible... si se ejecutara
    x * 2
});
println!("iterador construido");
// Salida hasta aqui: solo "iterador construido".
// map no ha llamado a la clausura ni una vez.

El println! de dentro del map no se imprime. Has fabricado un iterador que sabe cómo duplicar y anunciar, pero que no lo ha hecho. La descripción de la computación y su ejecución son dos momentos distintos, y hasta aquí solo ocurrió el primero.

El consumidor tira y el valor fluye

El trabajo empieza cuando un consumidor entra en escena: collect, sum, for, count… Un consumidor llama a next en la cabeza de la cadena, y esa llamada se propaga hacia atrás hasta la fuente:

let v: Vec<i32> = it.collect();
// AHORA si: se imprime "procesando 1", "procesando 2", "procesando 3"
// y v queda [2, 4, 6].

Conviene ver la dirección exacta del movimiento. La demanda viaja hacia atrás: collect pide a filter, filter pide a map, map pide al rango. El valor viaja hacia adelante: el rango produce un 1, map lo dobla a 2, filter decide, y si pasa llega a collect. Y —esto es lo crucial— fluye un solo elemento cada vez por toda la tubería. No hay un vector intermedio con todos los map hechos y luego otro con los filter: cada valor recorre la cadena entera antes de que el siguiente empiece.

let suma: i64 = (1..=1_000_000)
    .map(|x| x * 2)
    .filter(|x| x % 3 == 0)
    .sum();
// Ni un solo Vec de un millon de elementos se materializa:
// los valores pasan de uno en uno por toda la cadena.

Ese comportamiento tiene nombre y recompensa: la fusión. Como toda la cadena es perezosa y está monomorfizada, el compilador la ve entera en el punto de consumo y genera el mismo código máquina que habrías escrito con un bucle a mano. La cadena declarativa de arriba y este bucle imperativo son, para el compilador, indistinguibles:

let mut suma: i64 = 0;
for x in 1..=1_000_000 {
    let y = x * 2;              // lo que hacia map
    if y % 3 == 0 {            // lo que decidia filter
        suma += y;             // lo que acumulaba sum
    }
}

Escribes la versión legible y pagas el precio de la ilegible: cero abstracción sobrante. Esa es la promesa de “abstracción de coste cero” cumplida, esta vez, sobre los iteradores.

must_use: el compilador vigila la pereza

La pereza tiene una trampa clásica: construir una cadena y olvidar consumirla. Como el iterador no hace nada por sí mismo, ese olvido significa que tu trabajo nunca ocurre. Rust lo sabe y marca Iterator como #[must_use]:

(1..=3).map(|x| println!("{x}"));  // WARNING: iterador sin usar, no hace nada

El compilador te avisa de que has descrito una computación y la has tirado a la basura sin ejecutarla. Si querías el efecto, faltó un consumidor —un for, un .collect(), un .count() o el idiomático .for_each(...)—.

Iteradores infinitos, domados por la pereza

Si los adaptadores ejecutaran al vuelo, un iterador sin fin sería un cuelgue garantizado. Como no ejecutan, puedes describir lo infinito y recortarlo después:

let primeros_cinco: Vec<u64> = (0..)          // rango infinito: 0, 1, 2, ...
    .map(|n| n * n)                           // cuadrados, perezoso
    .take(5)                                  // corta en cinco, perezoso
    .collect();                               // [0, 1, 4, 9, 16]

(0..) promete infinitos naturales, pero take(5) solo pedirá cinco next, así que la fuente solo produce cinco valores. La pereza convierte “infinito” en algo con lo que se programa a diario: describes el todo y consumes la parte.

🧱

Adaptador · perezoso

Devuelve otro iterador y no ejecuta nada: map, filter, take, zip, enumerate, skip. Se encadenan sin coste.

Consumidor · ejecuta

Devuelve un valor o una colección y dispara toda la cadena: collect, sum, count, for, find, any.

💡
La regla para leer una cadena de iteradores

Recorre la cadena y clasifica cada eslabón. ¿Devuelve otro iterador? Es un adaptador y es perezoso: map, filter, take, zip, enumerate, skip… ¿Devuelve un valor concreto o una colección? Es un consumidor y es el que dispara todo: collect, sum, count, for, find, any. Una cadena sin consumidor final no hace nada; ese es el primer sitio donde mirar cuando “el iterador no se ejecuta”.

flowchart RL
cons[collect pide el siguiente valor] --> filt[filter lo pide al anterior]
filt --> mapa[map lo pide al anterior]
mapa --> src[El rango produce un numero]
src --> tmap[map lo transforma]
tmap --> tfil[filter lo acepta o lo descarta]
tfil --> tcon[collect lo acumula]
style cons fill:#cba6f7,color:#11111b
style src fill:#a6e3a1,color:#11111b
style tcon fill:#89b4fa,color:#11111b
La pereza separa la descripción de la ejecución

La evaluación perezosa parece un truco de eficiencia, pero es una decisión sobre qué es un programa. Al construir a.map(f).filter(g), no estás calculando: estás componiendo una descripción —un pequeño grafo de computación cuyos nodos son los adaptadores—. La ejecución es un acto aparte, disparado por el consumidor, y esa separación entre describir y ejecutar tiene tres consecuencias enormes. La primera es la ausencia de intermediarios: como los valores fluyen de uno en uno por toda la tubería y no por etapas, no se materializa ningún Vec temporal entre map y filter; una cadena de veinte adaptadores sigue haciendo una sola pasada con memoria constante, algo imposible si cada método construyera su colección. La segunda es que lo infinito se vuelve tratable: describir 0.. no cuesta nada porque describir no es producir; solo el consumidor decide cuántos valores arrancar, y con take, find o any arranca los justos. La tercera, y la que corona el diseño de Rust, es la fusión: como toda la cadena está monomorfizada y es perezosa, el compilador ve la tubería entera en el punto de consumo y la colapsa en un único bucle máquina, equivalente al que habrías escrito a mano con un índice y un acumulador. Obtienes la expresividad de la programación funcional —componer transformaciones como quien encaja piezas— con el rendimiento del bucle imperativo, sin intermediarios ni sobrecoste. Esto es lo que los lenguajes funcionales perezosos como Haskell hacen por defecto en todo el lenguaje; Rust lo circunscribe con precisión de cirujano a los iteradores, donde la pereza es una ventaja pura y no una fuente de sorpresas. La cadena que escribes es un plan; el consumidor es el instante en que el plan se vuelve trabajo.

⚔️ Observa la pereza en acción
  1. Construye (1..=5).map(|x| { println!("map {x}"); x }) y no lo consumas. Comprueba que no se imprime nada y lee el warning de #[must_use].
  2. Añade .collect::<Vec<_>>() al final del punto 1 y observa en qué orden aparecen los println!: ¿todos los map seguidos, o intercalados con el avance de la cadena?
  3. Escribe (0..).filter(|n| n % 7 == 0).take(3).collect::<Vec<_>>() y explica por qué un rango infinito no cuelga el programa.
  4. Razona cuántos vectores intermedios se crean entre el rango y el resultado en let v: Vec<i32> = (1..=100).map(|x| x * x).collect();.
  5. Explica, en términos de “quién pide” y “quién produce”, por qué una cadena de adaptadores sin un consumidor al final es código muerto.