Qué es un closure: una función que recuerda su entorno
Un closure es una función anónima que captura variables del ámbito donde se define. La sintaxis `|x| x + 1`, la diferencia con una `fn`, y las tres formas en que captura el entorno: por referencia compartida, por referencia mutable o por valor, siempre eligiendo la menos restrictiva.
Una fn es una isla: solo ve sus argumentos y las constantes globales; el resto del programa no existe para ella. Un closure rompe esa clausura. Es una función anónima que puede mirar hacia afuera y capturar las variables del ámbito donde nace, arrastrándolas consigo como equipaje. Por eso let y = 10; let sumar = |x| x + y; compila y una fn equivalente no: el closure no es solo su cuerpo, es su cuerpo más el fragmento de entorno que atrapó. Entender qué captura, y cómo, es entender qué distingue de verdad a un closure de una función.
- Escribir un closure con la sintaxis
|x| x + 1y distinguirlo de unafn. - Entender qué significa “capturar” el entorno y qué variables entran en la captura.
- Distinguir las tres formas de captura: referencia compartida, referencia mutable y valor.
- Ver que el compilador elige siempre la forma de captura menos restrictiva que el cuerpo permite.
Una función que ve hacia afuera
Una función ordinaria no puede leer las variables locales de quien la rodea. Este código no compila:
fn main() {
let y = 10;
fn sumar(x: i32) -> i32 { x + y } // ERROR: `y` no existe para esta fn
}
Un closure sí. Se escribe con barras verticales que encierran los parámetros, seguidas del cuerpo:
fn main() {
let y = 10;
let sumar = |x| x + y; // captura `y` del entorno
println!("{}", sumar(5)); // 15
}
La sintaxis tiene grados de detalle. El cuerpo puede ser una expresión suelta o un bloque, y los tipos son opcionales porque casi siempre se infieren del uso:
let a = |x| x + 1; // tipos inferidos
let b = |x: i32| -> i32 { x + 1 }; // tipos explicitos, cuerpo en bloque
let c = || println!("sin parametros"); // cero parametros
Un closure sin capturas —como |x| x + 1— es casi una fn; su interés aparece en cuanto toca una variable de fuera, porque entonces debe llevársela consigo.
Capturar: la parte que “cierra”
El nombre viene de cerrar sobre (to close over) el entorno. Cuando el compilador ve un closure, examina su cuerpo, detecta las variables libres —las que no son parámetros ni locales del propio closure— y las captura. Solo entran las que el cuerpo realmente usa: mencionar y lo captura; no mencionarlo, no.
Bajo el capó, un closure es una struct anónima que el compilador genera: un campo por cada variable capturada, más una implementación de los traits Fn que hace invocable a ese valor. Este desazúcar es conceptual, pero fiel:
let y = 10;
let sumar = |x| x + y;
// El compilador fabrica, aproximadamente, esto:
struct SumarAnon<'a> { y: &'a i32 } // captura y por referencia
// e implementa Fn(i32) -> i32 llamando a self.y + x
Esa es la revelación de fondo: un closure es un dato. Ocupa espacio (el de sus capturas), se puede mover, guardar en una struct o pasar a otra función. Uno sin capturas ocupa cero bytes; uno que atrapa un Vec es tan grande como el manejador de ese Vec.
Desde la edición 2021 —y por tanto en 2024 por defecto— Rust practica la captura disjunta: si el cuerpo solo usa punto.x, el closure captura ese campo, no el punto completo. Esto permite que un closure tome &punto.x mientras otro código sigue usando punto.y, algo que la captura de la struct entera habría bloqueado. Es un detalle sutil con consecuencias grandes para el borrow checker.
Las tres formas de captura
Aquí está el corazón del asunto. El compilador no captura de una única manera: elige, para cada variable, la forma menos restrictiva que el cuerpo tolere. Hay tres, en orden creciente de exigencia.
Por referencia compartida (&T)
El cuerpo solo lee la variable. Se captura como &T. El dueño original sigue pudiendo leerla; nadie la muta.
Por referencia mutable (&mut T)
El cuerpo modifica la variable. Se captura como &mut T. Mientras el closure viva, nadie más puede tocarla.
Por valor (T)
El cuerpo necesita ser dueño: mueve el valor fuera, o lo requiere por posesión. La variable se traslada al closure.
Cada forma se ve en código. Si solo lees, basta una referencia compartida:
let saludo = String::from("hola");
let imprime = || println!("{saludo}"); // captura &saludo: solo lee
imprime();
println!("{saludo}"); // saludo sigue disponible
Si mutas, la captura sube a &mut, y el closure debe ser mut:
let mut cuenta = 0;
let mut incrementa = || cuenta += 1; // captura &mut cuenta
incrementa();
incrementa();
println!("{cuenta}"); // 2
Y si el cuerpo exige posesión —por ejemplo, moviendo el valor a otro lugar—, la captura es por valor y el original deja de estar disponible:
let datos = vec![1, 2, 3];
let consume = || { let propio = datos; propio.len() }; // mueve datos adentro
// println!("{datos:?}"); // ERROR: datos ya fue movido al closure
flowchart TB body[El compilador analiza el cuerpo del closure] --> q1[El cuerpo solo lee la variable] q1 -->|si| ref[Captura por referencia compartida] q1 -->|no| q2[El cuerpo la muta] q2 -->|si| mut[Captura por referencia mutable] q2 -->|no| val[Captura por valor] style ref fill:#a6e3a1,color:#11111b style mut fill:#89b4fa,color:#11111b style val fill:#fab387,color:#11111b
La regla “la menos restrictiva posible” no es un capricho: maximiza lo que puedes seguir haciendo con las variables originales. Capturar por referencia deja el valor donde estaba; capturar por valor lo consume. Rust prefiere lo primero salvo que el cuerpo obligue a lo segundo. En la próxima lección veremos que esta elección de captura determina, además, qué traits (Fn, FnMut, FnOnce) implementa el closure.
Un programa se hace de dos sustancias que solemos pensar por separado: el código, que dice qué hacer, y los datos, sobre los que actúa. Una función pura pertenece al primer reino y no sabe nada del segundo salvo lo que le pasas por la puerta. El closure disuelve esa frontera: coge un fragmento del entorno —el conjunto de bindings vivos en un punto del programa— y lo empaqueta junto al código que lo usa, formando un solo valor que puedes guardar, mover y pasar como cualquier otro. Esto es, literalmente, la idea que da nombre al cálculo lambda: una función no es solo su cuerpo, sino su cuerpo cerrado sobre un entorno de ligaduras. Lenguajes con recolector de basura implementan esa idea de forma cómoda y opaca: capturan todo por referencia y dejan que el recolector decida cuándo muere el entorno atrapado. Rust hace lo mismo, pero se niega a esconder el precio. Cada captura tiene una forma —prestas o posees— y esa forma la elige el compilador buscando la mínima restricción, pero la verifica el borrow checker con el mismo rigor que a cualquier otra referencia: un closure que captura &mut cuenta bloquea el acceso a cuenta mientras vive, exactamente como lo haría un préstamo mutable explícito, porque es un préstamo mutable, solo que guardado dentro de una struct anónima. Así, el closure de Rust no es azúcar sintáctico sobre punteros a función: es la reificación del entorno léxico como dato tangible, con dueño, tamaño y tiempo de vida conocidos. Cuando entiendes que un closure es una struct que el compilador escribe por ti —un campo por captura, más una regla para invocarla—, dejas de verlo como magia y empiezas a razonar sobre él como sobre cualquier otro valor: cuánto ocupa, qué presta, cuándo muere. Esa desmitificación es la puerta a todo lo que viene: los tres traits, move, y el estilo funcional entero de Rust.
- Escribe
let f = |x| x + 1;y llámalo; confirma que no captura nada porquexes un parámetro, no una variable libre. - Declara
let base = 100;y un closure que devuelvax + base; identifica quebasese captura por referencia compartida y sigue usable después. - Convierte el punto 2 en un contador mutable con
let mut n = 0;y un closure que hagan += 1; explica por qué el closure debe declararsemut. - Crea un
String, escribe un closure que lo mueva a una variable interna, e intenta usar elStringoriginal después; lee el error de valor movido. - Dibuja en papel la struct anónima que el compilador generaría para el closure del punto 2: qué campo tiene y de qué tipo (
&i32).