Iteradores y closures en Rust
1. Descripción general
Closures e iteradores son dos herramientas que te permiten procesar datos de forma funcional en Rust manteniendo un rendimiento equivalente a los bucles escritos manualmente. Este artículo explica cómo los closures capturan variables del entorno y cubre los tres traits Fn, FnMut, FnOnce. Luego veremos el trait Iterator, los adaptadores más comunes y cómo escribir tu propio iterador. Después de leerlo, escribirás pipelines como filter → map → collect con confianza. También entenderás por qué el compilador a veces rechaza tu código.
Todos los ejemplos usan Rust 1.80+ (edición 2021), solo requieren la librería estándar.
2. Closures básicos
2.1. Sintaxis
Un closure es una función anónima que puede “ver” variables en el ámbito que la rodea:
fn main() {
let discount = 10; // porcentaje
let apply_discount = |price: u32| price - price * discount / 100;
println!("{}", apply_discount(250));
println!("{}", apply_discount(1000));
}
225
900
Los parámetros van entre dos barras verticales |...|, el cuerpo del closure viene justo después. El closure usa la variable discount aunque no se le pasa como parámetro. Una función fn normal no puede hacer esto.
Los tipos de parámetro y el tipo de retorno generalmente se infieren automáticamente. Puedes escribir |price| ... si el compilador tiene suficiente información. Sin embargo, el tipo solo se infiere una vez. Si la primera llamada es con u32 y la segunda con i64, obtendrás un error de compilación.
2.2. Tres formas de capturar variables
Un closure captura variables de la forma “más ligera” que su cuerpo necesita. El orden de preferencia es: borrow inmutable, borrow mutable, y finalmente tomar ownership.
fn main() {
let mut cart = vec!["bolígrafo"];
let print_cart = || println!("Carrito: {:?}", cart); // borrow &cart
print_cart();
let mut add_item = |item| cart.push(item); // borrow &mut cart
add_item("cuaderno");
add_item("regla");
println!("Final: {:?}", cart);
}
Carrito: ["bolígrafo"]
Final: ["bolígrafo", "cuaderno", "regla"]
Analicemos cada closure:
print_cartsolo leecart, por lo que captura un&cart.add_itemllama apush, así que necesita&mut cart. La variable del closure también debe declararsemut, porque llamarla modifica su estado interno.- El código compila gracias a los lifetimes no léxicos.
print_cartno se usa después de su primera llamada, así que el borrow inmutable termina antes de que comience el borrow mutable.
Cuando necesitas que el closure sea propietario de los datos, agregas la palabra clave move. El caso típico es pasar el closure a otro thread:
use std::thread;
fn main() {
let customer = String::from("Lan");
let handle = thread::spawn(move || {
println!("Procesando pedido de {customer}");
});
handle.join().unwrap();
}
Procesando pedido de Lan
Sin move, el compilador reportaría un error. El nuevo thread puede vivir más que main, así que una referencia a customer no sería segura. Con move, el String se transfiere completamente al closure. Después de esa línea, no puedes usar customer en main.
3. Los tres traits Fn, FnMut y FnOnce
Cada closure automáticamente implementa uno o varios de estos tres traits. Cuál implementa depende de qué hace el closure con las variables capturadas:
| Trait | Lo que el closure hace con variables capturadas | Puede llamarse |
|---|---|---|
Fn |
Solo lectura | Varias veces |
FnMut |
Modifica | Varias veces |
FnOnce |
Consume (move hacia afuera) | Una vez |
Estos tres traits tienen una relación de herencia: todo Fn es también FnMut, y todo FnMut es también FnOnce. Al escribir una función que acepta un closure, deberías elegir el trait más flexible que necesites. De esta forma, quien la llama tiene más opciones.
fn run_twice<F: Fn()>(f: F) {
f();
f();
}
fn run_twice_mut<F: FnMut()>(mut f: F) {
f();
f();
}
fn run_once<F: FnOnce() -> String>(f: F) -> String {
f()
}
fn main() {
let name = String::from("Minh");
run_twice(|| println!("Hola {name}"));
let mut count = 0;
run_twice_mut(|| count += 1);
println!("count = {count}");
let receipt = run_once(move || name + " - pagado");
println!("{receipt}");
}
Hola Minh
Hola Minh
count = 2
Minh - pagado
Las tres llamadas corresponden a los tres traits:
- El primer closure solo lee
name, así que esFn. - El segundo closure incrementa
count, así que esFnMut. Pasarlo arun_twicecausaría un error. - El último closure usa
name + "...". El operador+enStringtoma ownership dename, así que el closure solo se puede llamar una vez e implementa soloFnOnce.
3.1. Devolver un closure desde una función
Cada closure tiene su propio tipo anónimo único. Por eso no puedes escribir el nombre de ese tipo. Hay dos formas de devolver un closure:
fn make_discount(percent: u32) -> impl Fn(u32) -> u32 {
move |price| price - price * percent / 100
}
fn pick_rule(is_vip: bool) -> Box<dyn Fn(u32) -> u32> {
if is_vip {
Box::new(|price| price * 80 / 100)
} else {
Box::new(|price| price)
}
}
fn main() {
let summer_sale = make_discount(15);
println!("{}", summer_sale(200));
let rule = pick_rule(true);
println!("{}", rule(500));
}
170
400
impl Fn(...)se usa cuando la función siempre devuelve un tipo de closure específico. Este enfoque no asigna en el heap y no tiene costo de llamada dinámica.Box<dyn Fn(...)>se usa cuando diferentes ramas devuelven closures distintos. Los dos closures enpick_ruletienen tipos diferentes, así que deben unificarse bajo un trait object.moveenmake_discountes obligatorio. Sin él, el closure tomaría prestadopercent, que desaparece al final de la función.
4. El trait Iterator
4.1. El núcleo: la función next
Todo iterador en Rust gira en torno a un trait muy pequeño:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ... docenas de métodos proporcionados como map, filter, sum ...
}
next devuelve Some(elemento) hasta que se acaban los datos, luego devuelve None. Un bucle for es esencialmente sintaxis para llamar a next repetidamente.
4.2. iter, iter_mut e into_iter
Una colección puede proporcionar tres tipos diferentes de iteradores:
fn main() {
let mut prices = vec![100, 200, 300];
for p in prices.iter() { // p: &i32
print!("{p} ");
}
println!();
for p in prices.iter_mut() { // p: &mut i32
*p += 10;
}
for p in prices.into_iter() { // p: i32, vector es movido
print!("{p} ");
}
println!();
// println!("{:?}", prices); // error: valor usado después de move
}
100 200 300
110 210 310
iter()proporciona referencias inmutables. La colección aún se puede usar después.iter_mut()proporciona referencias mutables para modificar elementos en el lugar.into_iter()toma ownership. Escribirfor p in pricestambién llama ainto_iter()implícitamente.
5. Adaptadores y consumidores
Los métodos en un iterador se dividen en dos grupos:
- Adaptadores (
map,filter,take,zip,enumerate…) transforman este iterador en otro. Son lazy: no ejecutan nada hasta que alguien los consume. - Consumidores (
collect,sum,count,fold,for_each,find…) realmente llaman anexty producen un resultado.
Usaremos un struct Order como datos de ejemplo:
#[derive(Debug)]
pub struct Order {
pub id: u32,
pub customer: String,
pub total: u32,
pub paid: bool,
}
pub fn unpaid_total(orders: &[Order]) -> u32 {
orders.iter()
.filter(|o| !o.paid)
.map(|o| o.total)
.sum()
}
pub fn big_customers(orders: &[Order], threshold: u32) -> Vec<&str> {
orders.iter()
.filter(|o| o.total >= threshold)
.map(|o| o.customer.as_str())
.collect()
}
fn main() {
let orders = vec![
Order { id: 1, customer: "An".into(), total: 150_000, paid: true },
Order { id: 2, customer: "Bình".into(), total: 80_000, paid: false },
Order { id: 3, customer: "Chi".into(), total: 320_000, paid: false },
];
println!("Sin cobrar: {}", unpaid_total(&orders));
println!("Clientes grandes: {:?}", big_customers(&orders, 100_000));
let summary = orders.iter()
.fold(String::new(), |acc, o| acc + &format!("#{} ", o.id));
println!("Pedidos: {summary}");
}
Sin cobrar: 400000
Clientes grandes: ["An", "Chi"]
Pedidos: #1 #2 #3
Los pasos en el ejemplo:
filter(|o| !o.paid)acepta un closure que devuelvebool. El parámetrootiene tipo&&Order, porquefilterpasa una referencia al elemento, y el elemento deiter()ya es&Order. La desreferencia automática te permite escribiro.paidnormalmente.map(|o| o.total)transforma cada pedido en un número.sum()suma todo.collect()reúne los resultados en una colección. El tipo destino se toma del tipo de retornoVec<&str>de la función.foldacepta un valor inicial y un closure(acumulador, elemento) -> acumulador_nuevo. Este es el consumidor más general.
5.1. Evaluación lazy
El siguiente código muestra que los adaptadores solo se ejecutan cuando se consumen:
fn main() {
let ids = vec![1, 2, 3];
let doubled = ids.iter().map(|x| {
println!("map {x}");
x * 2
});
println!("Aún no hay nada ejecutándose");
let result: Vec<i32> = doubled.collect();
println!("{result:?}");
}
Aún no hay nada ejecutándose
map 1
map 2
map 3
[2, 4, 6]
Gracias a la evaluación lazy, una cadena filter → map → sum solo recorre los datos una vez sin crear colecciones intermedias. El compilador generalmente optimiza este pipeline en código máquina equivalente a un bucle escrito manualmente. Esta es la razón por la que los iteradores se llaman “abstracción de costo cero”.
6. Escribe tu propio iterador
Solo necesitas implementar next. Todos los adaptadores restantes vienen gratis. El siguiente ejemplo genera números de factura en el formato FC-0001:
pub struct InvoiceNumbers {
current: u32,
end: u32,
}
impl InvoiceNumbers {
pub fn new(start: u32, end: u32) -> Self {
Self { current: start, end }
}
}
impl Iterator for InvoiceNumbers {
type Item = String;
fn next(&mut self) -> Option<Self::Item> {
if self.current > self.end {
return None;
}
let number = format!("FC-{:04}", self.current);
self.current += 1;
Some(number)
}
}
fn main() {
let customers = ["An", "Bình", "Chi"];
for (invoice, customer) in InvoiceNumbers::new(7, 100).zip(customers) {
println!("{invoice} -> {customer}");
}
}
FC-0007 -> An
FC-0008 -> Bình
FC-0009 -> Chi
Explicación:
type Item = Stringdeclara el tipo de elemento que devuelve el iterador.nextactualiza el estado mediante&mut selfy devuelveNonecuando superaend.zipempareja dos iteradores en tuplas y se detiene cuando el más corto se agota. Por eso solo se imprimen tres líneas, aunque el rango va hasta 100.
7. Escribir pruebas
Rust tiene un framework de pruebas incorporado. Colocas un módulo de prueba en el mismo archivo que contiene unpaid_total e InvoiceNumbers:
#[cfg(test)]
mod tests {
use super::*;
fn order(id: u32, total: u32, paid: bool) -> Order {
Order { id, customer: format!("KH{id}"), total, paid }
}
#[test]
fn unpaid_total_only_counts_unpaid_orders() {
let orders = vec![order(1, 100, true), order(2, 50, false), order(3, 70, false)];
assert_eq!(unpaid_total(&orders), 120);
}
#[test]
fn unpaid_total_of_empty_slice_is_zero() {
assert_eq!(unpaid_total(&[]), 0);
}
#[test]
fn invoice_numbers_stop_after_end() {
let numbers: Vec<String> = InvoiceNumbers::new(9, 11).collect();
assert_eq!(numbers, vec!["FC-0009", "FC-0010", "FC-0011"]);
}
}
cargo test
running 3 tests
test tests::invoice_numbers_stop_after_end ... ok
test tests::unpaid_total_of_empty_slice_is_zero ... ok
test tests::unpaid_total_only_counts_unpaid_orders ... ok
test result: ok. 3 passed; 0 failed; 0 ignored
La segunda prueba verifica un caso límite. sum() en un iterador vacío devuelve 0, no hace panic.
8. Errores comunes y cuándo no usar esto
Nota: Olvidar un consumidor es el error más común. La línea
orders.iter().map(|o| println!("{}", o.id));no imprime nada. El compilador advierte “iterators are lazy and do nothing unless consumed”. Cuando solo necesites efectos secundarios, usa un bucleforofor_each.
Algunos otros errores que encontrarás:
collectno conoce el tipo destino. Escribirlet v = it.collect();causará un error “type annotations needed”. Necesitas especificarlet v: Vec<_> = ...o usar turbofishcollect::<Vec<_>>().- Usar
into_iter()y luego querer reutilizar la colección. Si solo necesitas leer, usaiter(). - El closure captura un borrow demasiado tiempo. Un closure
FnMutcapturando&mut cartte impide leercarthasta la última vez que uses el closure. Limita el alcance del closure o llámalo y luego lee. - Necesitar
?dentro demap. El operador?en un closure solo devuelve del closure, no de la función envolvente. Usacollect::<Result<Vec<_>, _>>(). Esto se detiene en el primer error y devuelveErr.
Cuándo no deberías forzar todo en una cadena de iteradores:
- La lógica tiene muchas ramas, muchos puntos
break/continue, o actualiza varias variables simultáneamente. Unforsimple es más legible que unfoldde diez líneas. - Necesitas devolver closures de muchas ramas pero te preocupa el rendimiento.
Box<dyn Fn>tiene costo de asignación y llamada dinámica. Considera usarenumo genéricos en su lugar.
9. Conclusión
Los closures en Rust capturan variables de la forma más ligera posible: borrow, borrow mutable, o move. De esto, los closures implementan automáticamente Fn, FnMut o FnOnce. Al escribir una función que acepta un closure, elige el trait más flexible que necesites.
Un iterador se basa en un solo método next. Adaptadores como map, filter, zip son lazy, mientras que consumidores como collect, sum, fold realmente ejecutan el pipeline. Combinando estas dos herramientas, escribes código conciso para procesar datos, seguro en memoria y tan rápido como bucles escritos manualmente. También deberías saber cuándo un simple for es la mejor opción.