wandres.dev
CLOSURES · Fn, FnMut, FnOnce

Closures move: forzar la captura por valor

La palabra `move` obliga al closure a capturar todo por valor, aunque el cuerpo solo lo lea. Por qué esto es imprescindible cuando el closure sobrevive a su ámbito: al devolverlo de una función, al lanzarlo a un hilo con `thread::spawn`, o al moverlo a una tarea async. Qué cambia y qué no respecto a los traits Fn.

⏱ 17 min

En la lección 21.1 vimos que el compilador captura por la vía menos restrictiva: si el cuerpo solo lee, captura por referencia. Eso es óptimo… hasta que el closure necesita sobrevivir a las variables que atrapó. Un closure que presta &datos no puede vivir más que datos; en cuanto lo devuelves de una función o lo mandas a un hilo, esa referencia quedaría colgando. La palabra clave move resuelve el conflicto de raíz: obliga a capturar todo por valor, transfiriendo la posesión al closure. Deja de prestar el entorno y pasa a poseerlo. Es la llave que abre los hilos, el async y todo closure que deba viajar lejos de donde nació.

🎯 Al terminar esta lección sabrás
  • Entender que move fuerza la captura por valor de todas las variables, aunque el cuerpo solo las lea.
  • Reconocer cuándo es obligatorio: al devolver un closure o al hacerlo sobrevivir a su ámbito.
  • Usar move con thread::spawn y comprender su relación con Send y 'static.
  • Ver que move cambia la captura, no el trait Fn: un closure move que solo lee sigue siendo Fn.

El problema: prestar lo que no sobrevive

Sin move, un closure que solo lee captura por referencia. Perfecto mientras el closure y el dato viven en el mismo ámbito; catastrófico si el closure escapa. El caso canónico es devolverlo de una función:

fn crea_sumador(base: i32) -> impl Fn(i32) -> i32 {
    |x| x + base   // ERROR: captura &base, pero base muere al retornar
}

base es un parámetro local que deja de existir cuando crea_sumador retorna. Un closure que lo prestara devolvería una referencia colgante, y el borrow checker lo prohíbe. La solución es poseer base en lugar de prestarlo:

fn crea_sumador(base: i32) -> impl Fn(i32) -> i32 {
    move |x| x + base   // base se copia dentro del closure, que ahora la posee
}

let suma10 = crea_sumador(10);
println!("{}", suma10(5)); // 15

Con move, el closure ya no depende del marco de pila de crea_sumador: se llevó su propia copia de base. Ahora puede vivir tanto como haga falta.

Qué hace move exactamente

move es un modificador que precede a las barras. Su efecto es simple y absoluto: toda variable capturada se toma por valor, ignorando la regla de “la menos restrictiva”. Lo que sin move sería &datos, con move es datos.

let datos = vec![1, 2, 3];

// Sin move: captura &datos (solo lee), datos sigue disponible.
let ver = || println!("{}", datos.len());
ver();
println!("{datos:?}");   // ok

let datos = vec![1, 2, 3];
// Con move: captura datos por valor, datos se traslada al closure.
let poseer = move || println!("{}", datos.len());
poseer();
// println!("{datos:?}");  // ERROR: datos fue movido al closure

Para tipos Copy —como i32—, “mover” es copiar, así que el original sigue usable tras el move. Para tipos no-Copy —como Vec o String—, la posesión se transfiere de verdad y el original queda inaccesible.

flowchart LR
var[Variable datos en el ambito exterior] -->|sin move| ref[Closure guarda una referencia a datos]
var -->|con move| own[Closure posee datos dentro de si]
ref --> vive[Solo valido mientras datos siga vivo]
own --> viaja[Puede viajar a un hilo o retornarse]
style ref fill:#f38ba8,color:#11111b
style own fill:#a6e3a1,color:#11111b
style viaja fill:#89b4fa,color:#11111b

Por qué los hilos lo exigen

El uso estelar de move son los hilos. thread::spawn acepta un closure con el bound F: Send + 'static: el 'static significa que el closure no puede contener referencias prestadas a nada del ámbito que lo lanzó, porque el hilo hijo podría seguir vivo cuando ese ámbito ya murió. La única forma de cumplir 'static capturando datos locales es poseerlos, y eso es exactamente lo que da move:

use std::thread;

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

    let handle = thread::spawn(move || {
        // El hilo posee datos; no hay referencia al marco de main.
        let suma: i32 = datos.iter().sum();
        println!("suma en el hilo: {suma}");
    });

    handle.join().unwrap();
}

Sin move, el closure prestaría &datos, incumpliría 'static y el compilador lo rechazaría con un mensaje sobre tiempos de vida. move transfiere datos al hilo, que se convierte en su dueño; cuando el hilo termina, libera datos él mismo. La misma lógica gobierna el async (nivel 32): un future generado por un bloque async move debe poseer lo que usa, porque se moverá entre puntos await y quizá se ejecute en otro hilo del runtime, mucho después y muy lejos de donde se creó.

💡
Clonar antes del move para conservar el original

Si necesitas seguir usando un valor tras mandarlo a un hilo, clónalo antes y mueve la copia: let copia = datos.clone(); thread::spawn(move || usar(copia));. El hilo posee copia; el hilo principal conserva datos. Es el patrón habitual cuando varios hilos necesitan, cada uno, su propia versión de un dato de arranque.

Todos estos escenarios comparten una raíz —el closure sobrevive a su ámbito de origen— y por eso todos reclaman move:

↩️

Devolver un closure

Una función que retorna impl Fn capturando sus locales debe poseerlos: al retornar, el marco muere y cualquier préstamo colgaría.

🧵

Lanzar un hilo

thread::spawn exige 'static: el hijo puede vivir más que el padre, así que el closure no puede prestar nada del marco lanzador.

Tareas async

Un async move se ejecuta más tarde y quizá en otro hilo del runtime; debe poseer lo que usa para cruzar los puntos await.

🗃️

Guardar en una struct

Un closure almacenado como campo, o enviado por un canal, vive más que su creador: solo poseyendo sus datos evita referencias muertas.

move no cambia el trait Fn

Un malentendido frecuente: creer que move hace del closure un FnOnce. No. move decide cómo se capturan las variables (por valor); los traits Fn, FnMut, FnOnce dependen de qué hace el cuerpo con ellas una vez capturadas. Son ejes independientes.

let texto = String::from("hola");
let repetible = move || texto.len();   // posee texto, pero solo lo LEE

println!("{}", repetible());   // se puede llamar
println!("{}", repetible());   // ...muchas veces: es Fn

repetible capturó texto por valor (move) pero su cuerpo solo lo lee, así que implementa Fn y se invoca cuantas veces quieras. Un move que mutara su copia sería FnMut; uno que la consumiera sería FnOnce. La palabra move gobierna la posesión; el cuerpo gobierna el trait. Confundir ambos ejes lleva a errores difíciles de leer.

Poseer es la condición de poder viajar

Hay una asimetría profunda entre prestar y poseer que move pone al desnudo, y que explica por qué esta humilde palabra clave es la bisagra de la concurrencia en Rust. Una referencia es un contrato atado a un lugar y un instante: &datos promete que datos seguirá existiendo, intacto, durante toda la vida de la referencia, y el borrow checker verifica esa promesa dibujando tiempos de vida sobre el código. Ese contrato es barato y seguro mientras prestamista y prestatario compartan el mismo marco temporal. Pero un hilo rompe esa unidad de tiempo: el hijo puede seguir corriendo cuando el padre ya retornó y liberó su pila, de modo que cualquier referencia al marco del padre se habría convertido en un puntero a memoria muerta. La computación concurrente es, en el fondo, computación cuyas piezas dejan de compartir línea temporal, y en ese mundo prestar es imposible de verificar: no hay un “mientras ambos vivan” cuando no sabes quién vivirá más. La única relación que sobrevive a la separación temporal es la posesión, porque poseer no depende de que otro siga vivo: si el closure es dueño de datos, no le importa qué fue del marco donde datos nació, porque datos viaja con él y morirá cuando él muera. Por eso thread::spawn pide 'static —ninguna referencia a préstamo local— y por eso move es la respuesta: transforma un puñado de préstamos en un puñado de posesiones, cortando el cordón umbilical que ataba el closure a su ámbito de origen. La lección trasciende los hilos. move es el mecanismo general para fabricar valores autónomos: closures que se devuelven de funciones, que se guardan en structs de vida larga, que se envían por canales, que se agendan como tareas async para ejecutarse dentro de una hora. Todos comparten la misma necesidad —sobrevivir a su cuna— y la misma solución —poseer en vez de prestar—. Rust no esconde este coste tras un recolector que mantendría vivo el entorno mágicamente; te hace nombrarlo con cuatro letras y te da, a cambio, la garantía de que ningún closure viajero arrastra jamás una referencia a un pasado que ya no existe.

⚔️ Haz que tus closures viajen
  1. Escribe fn contador_desde(inicio: i32) -> impl FnMut() -> i32 que devuelva un closure move con un contador propio; llámalo varias veces y observa cómo avanza.
  2. Quita el move del punto 1 y lee el error sobre el tiempo de vida de inicio; explica por qué devolver el closure obliga a poseer.
  3. Lanza un thread::spawn que capture un Vec local con move, sume sus elementos y devuelva el total por join.
  4. Repite el punto 3 sin move y relaciona el error con los bounds Send + 'static de spawn.
  5. Escribe un closure move que capture un String pero solo lo lea; confirma que implementa Fn llamándolo dos veces, y razona por qué move no lo degradó a FnOnce.