Colecciones e iteradores en Rust

Aprende Rust → Lección 6

Con dos colecciones se cubre casi todo lo que se escribe a diario: el Vec, que es una lista que crece, y el HashMap, que asocia claves con valores. Pero lo que de verdad define cómo se programa en Rust no son ellas, sino los iteradores: una forma de describir qué quieres obtener de una colección en lugar de escribir el bucle que lo recorre. Esta lección va de las dos cosas, en ese orden.

Vec: la lista que crece

Un Vec<T> guarda muchos valores del mismo tipo, uno detrás de otro, y puede crecer durante la ejecución. Por dentro son tres datos y un bloque de memoria aparte:

Cómo está hecho un Vec por dentro El valor Vec, en la pila puntero longitud = 3 capacidad = 4 Los elementos, en el montón 10 20 30 libre índice 0 índice 1 índice 2 reservado Mientras quede capacidad, añadir al final no cuesta nada; al agotarse, el Vec pide un bloque mayor y copia lo que tenía.
La longitud es cuántos elementos hay; la capacidad, cuántos caben antes de tener que mudarse a otro sitio.

En el día a día esa estructura es invisible y se trabaja con un puñado de métodos:

fn main() {
    let mut notas: Vec<i32> = Vec::new();
    notas.push(90);
    notas.push(75);
    notas.push(88);

    let dias = vec!["lunes", "martes", "miércoles"];

    println!("{} notas, {} días", notas.len(), dias.len());
    println!("la primera nota es {}", notas[0]);
    println!("la última fue {:?}", notas.pop());
    println!("¿hay una décima nota? {:?}", notas.get(9));
}

La macro vec! es el atajo para crear uno con valores ya dentro. Y hay dos formas de leer un elemento que conviene no confundir, porque se comportan de manera muy distinta cuando el índice no existe:

notas[9] frente a notas.get(9). Con los corchetes, un índice fuera de rango detiene el programa con index out of bounds. Con get obtienes un Option: Some si existe y None si no, y decides tú qué hacer. Usa los corchetes solo cuando el índice venga de un sitio en el que ya sabes que es válido, como un bucle sobre la propia colección.

Recorrerlo de tres maneras

Aquí reaparece la idea de propiedad de la guía, y es el punto donde más se tropieza. Un for sobre una colección puede pedir prestados los elementos, pedirlos prestados para modificarlos, o quedárselos:

fn main() {
    let mut v = vec![1, 2, 3];

    for n in &v {
        print!("{n} ");
    }
    println!();

    for n in &mut v {
        *n *= 10;
    }
    println!("{v:?}");

    for n in v {
        print!("{n} ");
    }
    println!();
}

Las tres formas tienen nombre propio como métodos: iter() presta cada elemento, iter_mut() los presta para modificarlos y into_iter() consume la colección entera. Escribir for n in &v es lo mismo que for n in v.iter(), solo que más corto.

La tercera forma tiene una consecuencia: después de for n in v, el vector ya no existe, porque el bucle se quedó con él. Si lo intentas usar en la línea siguiente, el programa no compila. Es exactamente la regla de propiedad, aplicada al bucle.

HashMap: asociar claves con valores

El HashMap<K, V> guarda pares. No viene en el preludio, así que hay que importarlo:

use std::collections::HashMap;

fn main() {
    let mut stock = HashMap::new();
    stock.insert("tornillos", 120);
    stock.insert("tuercas", 80);

    match stock.get("tornillos") {
        Some(cantidad) => println!("hay {cantidad} tornillos"),
        None => println!("no tenemos tornillos"),
    }

    println!("¿hay arandelas? {}", stock.contains_key("arandelas"));

    let mut claves: Vec<_> = stock.keys().collect();
    claves.sort();
    println!("artículos: {claves:?}");
}

Fíjate en que get devuelve un Option, no el valor directamente: la clave puede no estar, y el tipo lo dice. Y fíjate también en que ordeno las claves antes de imprimirlas, porque el orden de recorrido de un HashMap no está garantizado y puede cambiar entre ejecuciones. Si necesitas un orden estable, hay que ordenar al salir o usar un BTreeMap, que mantiene las claves ordenadas.

La API entry, que ahorra la mitad del código

El patrón más frecuente con un mapa es «si la clave ya está haz una cosa, y si no, otra». Escrito a mano son cuatro o cinco líneas con un match; con entry es una:

use std::collections::HashMap;

fn main() {
    let texto = "el gato y el perro y el pez";
    let mut cuenta: HashMap<&str, i32> = HashMap::new();

    for palabra in texto.split_whitespace() {
        *cuenta.entry(palabra).or_insert(0) += 1;
    }

    let mut pares: Vec<_> = cuenta.into_iter().collect();
    pares.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));

    for (palabra, veces) in pares {
        println!("{palabra}: {veces}");
    }
}

La línea clave merece leerse despacio. entry(palabra) localiza el hueco de esa clave, or_insert(0) pone un cero si estaba vacío y en cualquier caso devuelve una referencia mutable al valor, y el asterisco de delante sirve para llegar al número a través de esa referencia y sumarle uno. Contar cosas es el ejemplo clásico, pero el mismo patrón sirve para agrupar: con or_insert_with(Vec::new) y un push detrás, se construye un mapa de listas.

Los iteradores, que son el verdadero tema

Un iterador es un valor que sabe entregar elementos de uno en uno. Lo interesante es que se pueden encadenar transformaciones sobre él, y que esas transformaciones no hacen nada hasta que alguien pide el resultado.

Fuente, adaptadores y consumidor v.iter() la fuente .filter(…) adaptador .map(…) adaptador .sum() el consumidor perezosos: todavía no ha pasado nada aquí se recorre, una sola vez Sin consumidor no se ejecuta nada: la cadena describe el cálculo, no lo realiza.
Encadenar diez adaptadores no implica diez recorridos. El consumidor hace una única pasada aplicando todo a cada elemento.

Un ejemplo con las piezas en su sitio:

fn main() {
    let notas = vec![90, 45, 75, 100, 62, 88];

    let suma_aprobados: i32 = notas
        .iter()
        .filter(|&&n| n >= 70)
        .sum();

    let subidas: Vec<i32> = notas
        .iter()
        .map(|n| n + 5)
        .collect();

    println!("suma de aprobados: {suma_aprobados}");
    println!("subidas: {subidas:?}");
    println!("cuántos aprobados: {}", notas.iter().filter(|&&n| n >= 70).count());
    println!("la mejor: {:?}", notas.iter().max());
    println!("¿alguna perfecta? {}", notas.iter().any(|&n| n == 100));
}

Esa escritura reemplaza a los bucles con acumulador que se escriben en otros lenguajes, y tiene dos ventajas concretas: se lee de arriba abajo como una descripción de lo que quieres, y el compilador la traduce a un bucle tan eficiente como el que escribirías a mano.

El error de principiante con los iteradores. Si escribes una cadena y no la consumes, no ocurre absolutamente nada. Un v.iter().map(|x| println!("{x}")); no imprime una sola línea: construye un iterador y lo tira. El compilador lo avisa con unused `Map` that must be used y la nota «iterators are lazy and do nothing unless consumed»; en ese caso concreto incluso sugiere el arreglo, cambiar map por for_each. Cuando una cadena «no funciona», lo primero que hay que mirar es si falta el consumidor.

Las piezas que más se usan

Adaptadores (perezosos) Qué hacen
map(f) Transforma cada elemento
filter(p) Deja pasar solo los que cumplen la condición
enumerate() Empareja cada elemento con su índice
zip(otro) Recorre dos colecciones a la vez, en parejas
rev() Lo recorre del final al principio
take(n), skip(n) Se queda con los primeros n, o se salta los primeros n
filter_map(f) Transforma y descarta en un solo paso, quedándose con los Some
Consumidores Qué devuelven
collect() Una colección nueva. Suele necesitar que anotes cuál
sum(), count() Un número
max(), min() Un Option, porque la colección puede estar vacía
find(p) El primero que cumple, como Option
position(p) El índice del primero que cumple, como Option
any(p), all(p) Un bool. Paran en cuanto saben la respuesta
fold(inicio, f) Un único valor, combinando todos. El caso general
for_each(f) Nada: se usa por el efecto, como un bucle

Combinadas resuelven en una expresión cosas que de otro modo serían un bucle con varias variables sueltas:

fn main() {
    let ciudades = vec!["Lima", "Quito", "Bogotá", "Caracas"];
    let habitantes = vec![9.7, 2.8, 7.9, 2.9];

    for (i, (ciudad, millones)) in ciudades.iter().zip(habitantes.iter()).enumerate() {
        println!("{}. {ciudad}: {millones} millones", i + 1);
    }

    let entradas = vec!["12", "no", "30", "x", "5"];
    let numeros: Vec<i32> = entradas
        .iter()
        .filter_map(|t| t.parse().ok())
        .collect();

    println!("números válidos: {numeros:?}");
    println!("total: {}", numeros.iter().sum::<i32>());

    let mas_poblada = ciudades
        .iter()
        .zip(habitantes.iter())
        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap());

    println!("la mayor: {mas_poblada:?}");
}

El filter_map de ese ejemplo es un buen resumen de la idea general. parse() devuelve un Result, ok() lo convierte en Option descartando el motivo del fallo, y filter_map se queda solo con los Some y los desempaqueta. Las entradas que no eran números desaparecen sin una sola comprobación escrita a mano, y sin que el programa se detenga por ellas.

En resumen

Lo que quieres hacer Qué usar
Una lista que crece Vec<T>, con push y pop
Leer un elemento que seguro existe v[i]
Leer uno que puede no existir v.get(i), que da un Option
Asociar claves con valores HashMap<K, V>
Claves siempre en orden BTreeMap<K, V>
Contar o agrupar por clave entry(k).or_insert(…)
Recorrer prestando los elementos for x in &coleccion
Recorrer para modificarlos for x in &mut coleccion
Recorrer quedándote con ellos for x in coleccion
Transformar y recoger en otra colección .map(…).collect()
Quedarte con los que cumplen algo .filter(…)
Convertir descartando los fallos .filter_map(…)

La idea que conviene llevarse es que en Rust el bucle explícito es la excepción, no la norma. Casi cualquier recorrido se expresa como una cadena de iteradores, y hacerlo así no es solo cuestión de estilo: al describir el qué en vez del cómo, desaparecen de golpe los índices mal calculados, los acumuladores mal inicializados y los recorridos que se salen del final.

Para verlo explicado

Miniatura del vídeo «14. Curso de RUST: VECTORES», del canal Luis Serrano Donaire

«14. Curso de RUST: VECTORES», del canal Luis Serrano Donaire (14:59). Material de otro canal que recomendamos como complemento, no una producción de decodigo.com. El reproductor solo se carga al pulsar, así que YouTube no recibe nada tuyo hasta entonces. También puedes verlo en YouTube.

Miniatura del vídeo «44.- Curso Rust. Hash Maps en Rust: Ownership, SipHash y Optimización Avanzada.», del canal Jesús Conde

«44.- Curso Rust. Hash Maps en Rust: Ownership, SipHash y Optimización Avanzada.», del canal Jesús Conde (23:26). Material de otro canal que recomendamos como complemento, no una producción de decodigo.com. El reproductor solo se carga al pulsar, así que YouTube no recibe nada tuyo hasta entonces. También puedes verlo en YouTube.