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.
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.
- Emparejar, concatenar e indexar flujos con
zip,chainyenumerate. - Recortar secuencias con
take,skipy sus variantes_while. - Aplanar estructuras anidadas con
flat_mapy llevar estado conscan. - Distinguir los adaptadores de
Iteratorde los métodos de slicewindowsychunks.
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]);
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
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 anidado —Take<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.
- Usa
zippara emparejar["a", "b", "c"]con1..y obtén[("a",1), ("b",2), ("c",3)]; explica por qué el rango infinito no cuelga el programa. - Reescribe un bucle con índice manual que imprima posición y valor usando
enumerate, y razona por qué equivale azipcontra0... - Genera los primeros diez cuadrados perfectos con
(1..).map(|n| n * n).take(10)y discute el papel detakefrente a una fuente ilimitada. - Aplana
vec![vec![1, 2], vec![3], vec![4, 5]]en[1, 2, 3, 4, 5]conflat_map, y luego conflatten; compara ambas versiones. - Calcula el máximo deslizante de ventana 3 sobre
[4, 1, 7, 2, 9]conwindows(3), y explica por quéwindowsvive en el slice y no enIterator.