wandres.dev
CLOSURES · Fn, FnMut, FnOnce

Closures e iteradores: el estilo funcional de Rust

Los adaptadores de iterador —map, filter, filter_map, fold— toman closures, y encadenarlos es como Rust expresa transformaciones de datos sin bucles manuales. La pereza de los adaptadores, cómo un consumidor dispara la cadena, y por qué el resultado se compila a un bucle tan rápido como el escrito a mano.

⏱ 19 min

Los closures encuentran su hábitat natural en los iteradores. Casi todos los adaptadores de la biblioteca —map, filter, fold, take_while— reciben un closure que dice qué hacer con cada elemento, y encadenarlos convierte un bucle imperativo lleno de índices y variables auxiliares en una tubería declarativa que se lee de izquierda a derecha como una frase. Este es el estilo funcional de Rust, y tiene una virtud que lo distingue de otros lenguajes: es una abstracción de coste cero. La cadena de closures no construye listas intermedias ni paga indirecciones; el compilador la funde en un único bucle tan eficiente como el que habrías escrito a mano. Elegancia arriba, sin penalización abajo.

🎯 Al terminar esta lección sabrás
  • Encadenar map, filter y otros adaptadores que reciben closures para transformar datos.
  • Entender la pereza: los adaptadores no hacen nada hasta que un consumidor tira de la cadena.
  • Capturar el entorno dentro de un closure de iterador (por ejemplo, un umbral externo).
  • Comprender por qué la cadena se compila a un bucle sin coste de abstracción.

De un bucle manual a una tubería

Considera una tarea corriente: de una lista de números, quedarte con los pares, elevarlos al cuadrado y sumarlos. Al estilo imperativo:

let nums = [1, 2, 3, 4, 5, 6];
let mut total = 0;
for &n in &nums {
    if n % 2 == 0 {
        total += n * n;
    }
}
// total == 56

Funciona, pero mezcla tres ideas —filtrar, transformar, acumular— en un mismo cuerpo con estado mutable. La versión con iteradores y closures separa cada paso en un eslabón:

let nums = [1, 2, 3, 4, 5, 6];
let total: i32 = nums.iter()
    .filter(|&&n| n % 2 == 0)   // conserva los pares
    .map(|&n| n * n)            // eleva al cuadrado
    .sum();                     // acumula
// total == 56

Cada adaptador recibe un closure que expresa solo su paso: filter un predicado que devuelve bool, map una transformación. La cadena se lee como su descripción en palabras. No hay índices, no hay total mutable a la vista, no hay ocasión de equivocarse con un += mal puesto.

La pereza: nada ocurre sin un consumidor

La clave que hace eficiente todo esto: los adaptadores son perezosos. filter y map no recorren nada al llamarlos; devuelven un nuevo iterador que envuelve al anterior y recuerda el closure. La cadena entera es una estructura inerte hasta que un consumidorsum, collect, for_each, count— tira de ella pidiendo elementos uno a uno.

let it = (1..=1_000_000).map(|x| x * x).filter(|x| x % 2 == 0);
// Hasta aqui NO se ha calculado ni un cuadrado: it es solo la receta.
let primero = it.take(3).collect::<Vec<_>>(); // ahora si, y solo 3 elementos

Esto tiene una consecuencia enorme: la cadena procesa elemento a elemento, no etapa a etapa. No se construye un millón de cuadrados y luego se filtran; cada número atraviesa map y filter antes de que el siguiente entre. Por eso take(3) puede cortar tras tres resultados sin que se haya calculado el resto. Los adaptadores describen; los consumidores ejecutan.

flowchart LR
src[Fuente iter sobre nums] --> f[filter con predicado pares]
f --> m[map eleva al cuadrado]
m --> c[sum consumidor tira de la cadena]
c -.->|pide un elemento| m
m -.->|pide un elemento| f
f -.->|pide un elemento| src
style src fill:#cba6f7,color:#11111b
style c fill:#a6e3a1,color:#11111b

Capturar el entorno dentro de la cadena

Como cualquier closure, los de un iterador pueden capturar variables del ámbito. Es lo que vuelve la tubería paramétrica: un umbral, un factor o una tabla externa entran en la lógica sin salir de la cadena.

let palabras = ["rust", "es", "seguro", "y", "veloz"];
let minimo = 3;                                 // umbral capturado

let largas: Vec<&str> = palabras.iter()
    .filter(|p| p.len() >= minimo)              // captura &minimo
    .copied()
    .collect();
// largas == ["rust", "seguro", "veloz"]

El closure de filter cierra sobre minimo igual que cualquier otro closure de la lección 21.1: lo captura por referencia compartida porque solo lo lee. Cambia minimo y la tubería filtra distinto, sin tocar su estructura.

Otros adaptadores frecuentes, todos guiados por closures, cubren casi cualquier transformación:

let datos = ["1", "dos", "3", "x", "5"];

// filter_map: transforma y descarta en un solo paso.
let suma: i32 = datos.iter().filter_map(|s| s.parse::<i32>().ok()).sum();
// suma == 9  (1 + 3 + 5; "dos" y "x" se descartan)

// fold: acumula con un estado y un closure de dos argumentos.
let concat = ["a", "b", "c"].iter().fold(String::new(), |mut acc, s| {
    acc.push_str(s);
    acc
});
// concat == "abc"

// any / all / position: consultas que cortan en cuanto pueden.
let hay_par = [1, 3, 4, 7].iter().any(|&n| n % 2 == 0);   // true
💡
filter_map frente a filter y luego map

Cuando cada elemento puede transformarse o descartarse, filter_map con un closure que devuelve Option lo hace en una sola pasada: Some(v) conserva v, None lo elimina. Es más claro y más directo que un filter seguido de un map con unwrap, y encaja de maravilla con métodos como parse().ok() o checked_add, que ya devuelven Option.

Por qué no cuesta nada

La objeción natural: “tantas capas de closures e iteradores tienen que ser más lentas que un bucle”. En Rust, no. Cada adaptador es un tipo genérico parametrizado por el tipo del closure, y el closure es una struct de tamaño conocido cuyo cuerpo el compilador ve por completo. Al monomorfizar la cadena, inlina cada closure en su adaptador y cada adaptador en el siguiente, hasta colapsar toda la tubería en un solo bucle sobre la fuente. Tras las optimizaciones, el código máquina de la versión funcional y el del bucle imperativo son, con frecuencia, idénticos byte a byte.

// Estas dos funciones suelen compilar al MISMO codigo maquina:
fn imperativa(v: &[i32]) -> i32 {
    let mut s = 0;
    for &x in v { if x > 0 { s += x; } }
    s
}
fn funcional(v: &[i32]) -> i32 {
    v.iter().filter(|&&x| x > 0).sum()
}

Por eso el estilo funcional en Rust no es un lujo que se paga en velocidad: es la forma preferida de escribir transformaciones, legible arriba y sin coste abajo. Los closures son el ingrediente que lo hace posible, porque dan al compilador cuerpos concretos que inlinar en lugar de punteros a función opacos.

La abstracción que no deja huella

La promesa que casi ningún lenguaje cumple es que puedas escribir al nivel de tu pensamiento y ejecutar al nivel de la máquina sin renunciar a nada en el camino. La cadena de iteradores y closures de Rust es una de las demostraciones más limpias de que esa promesa es realizable. Cuando escribes iter().filter(...).map(...).sum() estás describiendo qué quieres —un filtrado, una transformación, una reducción— en el vocabulario del álgebra de secuencias, sin decir cómo recorrer, dónde guardar el acumulador ni cuándo parar. Ese vocabulario es composicional: cada adaptador es una función de iteradores a iteradores, y encadenarlos es componer funciones, con la misma libertad con que compones frases. En otros lenguajes esa elegancia se paga: cada map construye una lista intermedia, cada filter otra, cada closure es un objeto en el heap invocado por puntero, y el recorrido paga indirecciones y reservas que el bucle manual se ahorra; por eso los programadores acaban reescribiendo la tubería bonita como un bucle feo cuando el rendimiento aprieta. Rust rompe ese dilema gracias a la conjunción de tres decisiones que ya conoces: los adaptadores son perezosos, así que no materializan colecciones intermedias sino que empujan elementos de uno en uno; los closures son structs con cuerpo visible, no punteros opacos, así que el compilador puede inlinarlos; y los iteradores son genéricos monomorfizados, así que toda la cadena se especializa en el sitio y se funde. El resultado es que la torre de abstracciones —tres adaptadores, tres closures, un consumidor— se desploma en un único bucle apretado que un compilador de C reconocería como propio, a menudo idéntico instrucción por instrucción al que habrías escrito peleando con índices. No es que Rust haga rápido el estilo funcional a pesar de ser abstracto; es que su modelo de coste cero está diseñado para que la abstracción no deje huella en el binario. Y esa ausencia de huella cambia la ética del programador: dejas de elegir entre “claro” y “rápido” porque la elección era falsa, y escribes la tubería que expresa tu intención sabiendo que el compilador la convertirá en el bucle que habrías odiado escribir. Los closures son el eslabón que cierra el círculo, porque son la pieza que le da al compilador algo concreto que fundir donde otros lenguajes solo tienen una llamada indirecta que respetar.

⚔️ Piensa en tuberías, no en bucles
  1. Reescribe con iter().filter(...).map(...).sum() un bucle que sume los cuadrados de los números impares de un arreglo.
  2. Encadena filter_map(|s| s.parse::<f64>().ok()) sobre una lista de cadenas mezcladas y calcula el promedio de las que sí parsean.
  3. Captura un umbral min en el closure de un filter y comprueba que cambiar min altera el resultado sin tocar la estructura de la cadena.
  4. Usa fold para construir un String uniendo palabras con un separador, y compáralo con hacer lo mismo mediante join.
  5. Escribe la versión imperativa y la funcional de “sumar los positivos de un slice” y razona por qué el compilador puede generar el mismo código máquina para ambas.