wandres.dev
ITERADORES AVANZADOS · adaptadores y colecciones

Cadenas de iteradores: por qué son de coste cero

La afirmación fuerte y comprobable: una cadena de iteradores compila al mismo ensamblador que un bucle escrito a mano. Por qué la monomorfización, el inlining de next y la iteración interna la hacen posible, y qué la rompe.

⏱ 19 min

Cierra el nivel una afirmación que suena a exageración y no lo es: una cadena de iteradores —iter().filter().map().sum()— compila, con optimizaciones, al mismo código máquina que el bucle con índices que escribirías a mano. No “parecido”, ni “casi”. El mismo. No es un acto de fe ni una aproximación optimista: es un hecho verificable en el ensamblador que emite el compilador. Esta lección desarma la maquinaria que lo hace posible, para que dejes de sospechar de la abstracción y la uses por defecto.

🎯 Al terminar esta lección sabrás
  • Comprobar que una cadena de iteradores compila al mismo código que un bucle manual.
  • Explicar el papel de la monomorfización y del inlining de next.
  • Entender la iteración interna con fold frente a la externa con next.
  • Reconocer qué rompe el coste cero y cómo ayudar al optimizador.

La promesa, hecha comprobable

La afirmación es fuerte. Esta cadena de iteradores…

fn suma_pares_x10(v: &[i32]) -> i32 {
    v.iter().filter(|&&x| x % 2 == 0).map(|&x| x * 10).sum()
}

…y este bucle escrito a mano…

fn suma_pares_x10_manual(v: &[i32]) -> i32 {
    let mut total = 0;
    for &x in v {
        if x % 2 == 0 {
            total += x * 10;
        }
    }
    total
}

…producen, compilados con optimizaciones, el mismo código máquina. La cadena de alto nivel no lleva penalización alguna respecto al bucle que un programador de C escribiría a regañadientes: ni una asignación de más, ni una indirección, ni una llamada de función residual. Se comprueba mirando el ensamblador emitido, no confiando en la promesa.

Por qué: monomorfización, inlining e iteración interna

Tres mecanismos, actuando en secuencia, disuelven la abstracción hasta hacerla desaparecer:

Monomorfización. Cada adaptador es un tipo genérico distinto —Filter<Iter<i32>>, luego Map<Filter<...>>—. Al usarlos con tipos concretos, el compilador genera versiones especializadas, sin genericidad residual. La cadena se vuelve un tipo anidado concreto, sin una sola decisión de tipo pendiente para la ejecución.

Inlining de next. El next de cada adaptador es diminuto: el de map llama al next del iterador interior y le aplica la clausura; el de filter lo invoca en bucle hasta que el predicado pasa. Como son tan pequeños, el optimizador los inlina unos dentro de otros, colapsando la pila de llamadas anidadas en un único cuerpo sin saltos de función.

Iteración interna. Un consumidor como sum no tira elemento a elemento desde fuera; delega en fold o try_fold, que recorren el iterador desde dentro, en un bucle cerrado que el propio adaptador controla. Esa iteración interna le entrega al optimizador un bucle compacto y contiguo, ideal para eliminar comprobaciones de límites y, cuando procede, vectorizar.

// sum se apoya en fold: iteracion interna, un bucle cerrado
let total: i32 = (1..=100).fold(0, |acc, x| acc + x);
assert_eq!(total, 5050);

El resultado combinado: los tipos desaparecen (monomorfización), las llamadas desaparecen (inlining) y queda un solo bucle ceñido (iteración interna) que el backend optimiza igual que optimizaría el bucle manual, porque a esas alturas son el mismo bucle.

flowchart TB
chain[Cadena iter filter map sum] --> mono[Monomorfizacion tipos concretos anidados]
mono --> inl[Inlining de cada next diminuto]
inl --> intern[Iteracion interna un bucle cerrado]
intern --> asm[Mismo ensamblador que el bucle a mano]
style chain fill:#cba6f7,color:#11111b
style inl fill:#89b4fa,color:#11111b
style asm fill:#a6e3a1,color:#11111b

Cuando el iterador gana al bucle ingenuo

El coste cero no significa “empata siempre con el bucle”: a veces la cadena de iteradores supera al bucle manual ingenuo, y la razón es de nuevo la iteración interna. Cuando indexas un slice a mano con v[i], el compilador debe insertar una comprobación de límites en cada acceso, porque i podría salirse del rango. Un iterador recorre por punteros internos que, por construcción, no se salen: no necesita esa comprobación.

// Bucle por indice: cada v[i] puede exigir comprobacion de limites
fn por_indice(v: &[i32]) -> i32 {
    let mut s = 0;
    for i in 0..v.len() {
        s += v[i]; // acceso indexado
    }
    s
}

// Iterador: recorre sin indexar, sin comprobaciones de limites
fn por_iterador(v: &[i32]) -> i32 {
    v.iter().sum()
}

En la práctica, el optimizador suele demostrar que el índice del primer caso nunca se sale y elimina también sus comprobaciones, con lo que ambos empatan. Pero cuando no puede probarlo —accesos menos regulares, índices calculados—, el iterador conserva su ventaja sin esfuerzo. La moraleja invierte el prejuicio de siempre: el estilo de alto nivel no solo no penaliza, sino que a menudo le da al compilador más información con la que optimizar que el bucle escrito a mano.

Cuándo se rompe, y cómo ayudar

El coste cero es la norma, pero no una garantía mágica: se apoya en que el optimizador pueda ver e inlinar toda la cadena. Se resiente cuando:

  • Interpones despacho dinámico. Un Box<dyn Iterator> o una clausura tras dyn FnMut mete una vtable en medio: el optimizador ya no puede inlinar a través de ella. El coste cero es un privilegio del despacho estático.
  • Cruzas fronteras opacas. Una función no inlinable a mitad de la cadena, o un límite entre unidades de compilación sin optimización cruzada, corta la fusión.
  • Fuerzas materialización. Un collect intermedio a un Vec que solo vuelves a iterar asigna memoria sin necesidad; deja la cadena perezosa hasta el consumidor final.
⚙️

Iteración externa · next

El consumidor tira elemento a elemento con next. Flexible y componible; es la base de todos los adaptadores.

🔁

Iteración interna · fold

El iterador recorre desde dentro en un bucle cerrado. Da al optimizador el bucle contiguo que necesita para vectorizar.

La regla práctica: mantén las clausuras concretas —no las envuelvas en Box<dyn> sin motivo—, evita los collect intermedios y confía en el consumidor perezoso. Si mides una diferencia, compara el ensamblador con cargo asm antes de reescribir a mano; casi siempre descubrirás que ya eran idénticos.

El coste cero no es velocidad: es la abolición de un dilema

Conviene decir con precisión qué promete Rust cuando llama “de coste cero” a sus iteradores, porque la frase se malinterpreta como un mero superlativo de “rápido”. No lo es. La definición canónica, heredada de C++, tiene dos mitades igual de exigentes: lo que no usas no lo pagas, y lo que usas no podrías escribirlo a mano más rápido. Una cadena de iteradores cumple ambas al pie de la letra, y esa literalidad cambia el modo en que uno programa. Durante décadas, el ingeniero de sistemas vivió bajo una servidumbre tácita: cada abstracción que hacía el código legible —una función de orden superior, una capa de composición, un recorrido declarativo— era sospechosa de costar ciclos, y en los momentos críticos había que descenderla a mano, deshacer la elegancia, volver al bucle con índices y mutación por miedo al precio oculto. El coste cero no hace el código “un poco más rápido”; elimina la sospecha. Cuando sabes —y puedes verificar en el ensamblador— que iter().filter().map().sum() compila exactamente al bucle que habrías escrito degradando tu propio código, la disyuntiva entre “claro” y “rápido” simplemente deja de existir en ese punto. No es que ganes rendimiento: es que dejas de tener que elegir entre rendimiento y expresividad, y esa es una libertad de otra naturaleza. La consecuencia sobre el estilo es profunda y silenciosa: en Rust escribes la versión legible primero y por defecto, porque no hay un segundo acto en el que la tengas que traicionar. El optimizador no es un lujo que rescata al código lento; es el garante de un contrato que te permite razonar sobre tu programa en el nivel de abstracción correcto, sabiendo que el nivel inferior será idéntico al que escribirías a mano. Ahí reside la ambición última de Rust como lenguaje de sistemas: no ser un lenguaje de alto nivel que a veces alcanza el bajo nivel, ni uno de bajo nivel que a veces tolera la abstracción, sino uno donde la distancia entre ambos se ha reducido a cero por construcción, de modo que la abstracción no es un peaje que pagas sino un plano que se evapora, dejando en su lugar exactamente las instrucciones que el problema exigía y ni una más.

⚔️ Verifica el coste cero
  1. Escribe la versión con iteradores y la versión con bucle manual de “suma de los cuadrados de los impares” y convéncete de que calculan lo mismo.
  2. Explica, con tus palabras, cómo la monomorfización, el inlining de next y la iteración interna colaboran para fundir la cadena en un bucle.
  3. Describe qué le pasa al coste cero si envuelves la cadena en un Box<dyn Iterator>, y por qué el despacho dinámico corta el inlining.
  4. Identifica un collect intermedio innecesario en un fragmento de código y reescríbelo para mantener la cadena perezosa hasta el final.
  5. Investiga cargo asm o el visor de ensamblador de la Rust Playground y compara la salida de una cadena simple frente a su bucle equivalente.