wandres.dev
ITERADORES AVANZADOS · adaptadores y colecciones

Adaptadores potentes: componer flujos de datos

Los adaptadores que convierten la iteración en un lenguaje para describir flujos: emparejar con zip, concatenar con chain, indexar con enumerate, recortar con take y skip, aplanar con flat_map, llevar estado con scan y deslizar ventanas con windows y chunks.

⏱ 18 min

Un iterador en Rust es una descripción perezosa de un cálculo, no el cálculo mismo. Cada adaptador —map, filter, zip— envuelve al anterior y no consume nada hasta que un consumidor final tira de la cadena. Esa pereza es lo que permite componer decenas de transformaciones sin materializar colecciones intermedias: el pipeline entero se funde, al compilar, en un solo recorrido. Este nivel arranca con los adaptadores que convierten esa composición en un lenguaje expresivo para describir flujos de datos.

🎯 Al terminar esta lección sabrás
  • Emparejar, concatenar e indexar flujos con zip, chain y enumerate.
  • Recortar secuencias con take, skip y sus variantes _while.
  • Aplanar estructuras anidadas con flat_map y llevar estado con scan.
  • Distinguir los adaptadores de Iterator de los métodos de slice windows y chunks.

Emparejar, concatenar, indexar

zip toma dos iteradores y produce uno de pares: en cada paso avanza ambos y emite una tupla (a, b). Se detiene en cuanto el más corto se agota, lo que lo hace seguro frente a longitudes distintas y, de paso, útil con fuentes infinitas.

let nombres = ["Ana", "Beto", "Cira"];
let edades = [30, 25, 41];

let pares: Vec<(&str, i32)> = nombres.iter().copied().zip(edades).collect();
assert_eq!(pares[1], ("Beto", 25));

enumerate es un caso tan común que tiene nombre propio: adjunta a cada elemento su índice usize, empezando en cero. Conceptualmente es zip contra el rango infinito 0.., pero se lee mejor.

for (i, c) in "rust".chars().enumerate() {
    println!("{i}: {c}"); // 0: r, 1: u, 2: s, 3: t
}

chain hace lo contrario a zip: en vez de recorrer dos fuentes a la par, las concatena, agotando la primera y siguiendo con la segunda. Ambas deben producir el mismo Item.

let a = [1, 2];
let b = [3, 4];
let todo: Vec<i32> = a.iter().chain(b.iter()).copied().collect();
assert_eq!(todo, [1, 2, 3, 4]);
💡
Nada se calcula hasta que alguien consume

Todos estos adaptadores son perezosos: zip, map, take… construyen una descripción, no ejecutan nada. El trabajo solo ocurre cuando un consumidor —un bucle for, un collect, un sum— tira de la cadena. Por eso puedes anteponer take(5) a un iterador infinito sin colgar el programa: solo se producen los cinco elementos que se piden.

Recortar y aplanar

take(n) trunca la cadena a los primeros n elementos; skip(n) descarta esos primeros n y deja pasar el resto. Su verdadero poder aparece con fuentes ilimitadas: take es la tijera que hace finito un flujo infinito.

let primeros: Vec<u64> = (1..).take(5).collect(); // 1, 2, 3, 4, 5
let saltados: Vec<i32> = (0..10).skip(7).collect(); // 7, 8, 9

Cuando el corte no depende de una cuenta sino de una condición, están take_while y skip_while, que actúan mientras el predicado se cumple.

let subida: Vec<i32> = [1, 3, 8, 2, 9].iter().copied().take_while(|&x| x < 5).collect();
assert_eq!(subida, [1, 3]); // se detiene en el primer 8, no filtra el resto

flat_map es map seguido de aplanado: cada elemento se transforma en otro iterador, y todos se concatenan en un flujo único. Es la herramienta para expandir una estructura anidada en una plana.

let frases = ["hola mundo", "adios rust"];
let palabras: Vec<&str> = frases.iter().flat_map(|f| f.split_whitespace()).collect();
assert_eq!(palabras, ["hola", "mundo", "adios", "rust"]);

Equivale a .map(...).flatten(), pero expresa en un solo verbo la intención de expandir y unir.

Estado y ventanas

scan es un map con memoria: mantiene un estado mutable entre elementos y, en cada paso, decide qué emitir. Su clausura recibe &mut estado y el elemento, y devuelve un Option: Some(y) emite y, None corta la iteración. El acumulado clásico —sumas parciales— es su ejemplo canónico.

let parciales: Vec<i32> = [1, 2, 3, 4]
    .iter()
    .scan(0, |acc, &x| {
        *acc += x;
        Some(*acc)
    })
    .collect();
assert_eq!(parciales, [1, 3, 6, 10]);

Cierran el nivel dos métodos que no son adaptadores de Iterator, sino de los slices [T]: windows y chunks. windows(n) produce sub-slices solapados de longitud n, deslizando de uno en uno; chunks(n) los produce disjuntos, sin solape. Cada elemento es un &[T].

let v = [1, 2, 3, 4];
let w: Vec<&[i32]> = v.windows(2).collect(); // [1,2], [2,3], [3,4]
let c: Vec<&[i32]> = v.chunks(2).collect();  // [1,2], [3,4]
assert_eq!(w.len(), 3);
assert_eq!(c.len(), 2);

Que vivan en el slice y no en Iterator no es capricho: solapar ventanas exige poder mirar atrás sobre un buffer contiguo, algo que un iterador genérico —de un solo paso hacia adelante— no puede garantizar. La distinción es una lección de diseño, no un detalle.

flowchart LR
src[Fuente iteradora] --> zip[zip empareja con otra fuente]
zip --> enu[enumerate anade indice]
enu --> fil[filter o map transforma]
fil --> tk[take corta a n elementos]
tk --> out[Consumidor collect o for]
style src fill:#cba6f7,color:#11111b
style fil fill:#89b4fa,color:#11111b
style out fill:#a6e3a1,color:#11111b
El iterador es la abstracción que reconcilia expresividad y máquina

Durante medio siglo, describir un cálculo sobre una secuencia obligaba a elegir bando. O escribías un bucle explícito —rápido, cercano a la máquina, pero ruidoso: índices, mutación y condiciones de parada entrelazadas con la lógica de verdad— o subías a las funciones de orden superior —map, filter, reduce— y pagabas en asignaciones intermedias e indirección, como en casi todo lenguaje funcional con listas perezosas o streams que materializan cada etapa. El iterador de Rust rompe ese dilema por una razón estructural: cada adaptador es un tipo distinto, genérico sobre el anterior, y next está marcado para inlinarse. Cuando encadenas zip, enumerate, filter y take, no construyes cuatro objetos que se pasan datos por el heap; construyes un tipo anidadoTake<Filter<Enumerate<Zip<...>>>>— que la monomorfización aplana y el optimizador funde en un único bucle sin asignaciones ni saltos. Por eso flat_map no cuesta más que dos bucles anidados escritos a mano, y una cadena de diez adaptadores compila al mismo código máquina que el for con índices que habrías tecleado a regañadientes. La consecuencia filosófica es honda: en Rust la abstracción sobre secuencias no es una comodidad que pagas, sino una notación de coste cero para el mismo cálculo que el hardware iba a ejecutar de todos modos. Componer flujos deja de ser un lujo que te permites cuando el rendimiento no importa, y se vuelve el modo por defecto de expresar cualquier recorrido, precisamente porque no cuesta nada convertirlo en el bucle que un ingeniero de C habría escrito a mano. La expresividad, aquí, no se compra con ciclos: se hereda gratis de un diseño que puso el tipo, y no la indirección, en el centro de la abstracción.

⚔️ Compón tus propios flujos
  1. Usa zip para emparejar ["a", "b", "c"] con 1.. y obtén [("a",1), ("b",2), ("c",3)]; explica por qué el rango infinito no cuelga el programa.
  2. Reescribe un bucle con índice manual que imprima posición y valor usando enumerate, y razona por qué equivale a zip contra 0...
  3. Genera los primeros diez cuadrados perfectos con (1..).map(|n| n * n).take(10) y discute el papel de take frente a una fuente ilimitada.
  4. Aplana vec![vec![1, 2], vec![3], vec![4, 5]] en [1, 2, 3, 4, 5] con flat_map, y luego con flatten; compara ambas versiones.
  5. Calcula el máximo deslizante de ventana 3 sobre [4, 1, 7, 2, 9] con windows(3), y explica por qué windows vive en el slice y no en Iterator.