Closures como parámetros y retornos: impl Fn, dyn Fn y Box
Pasar y devolver closures obliga a elegir entre despacho estático y dinámico. `impl Fn` y los genéricos monomorfizan y no cuestan nada; `&dyn Fn` y `Box<dyn Fn>` usan vtable, permiten heterogeneidad y guardarlos en structs. Por qué los closures no tienen un tipo que puedas nombrar, y el trade-off entre ambas vías.
Un closure es un valor, pero un valor con una peculiaridad: su tipo no tiene nombre. El compilador fabrica una struct anónima por cada closure, y tú no puedes escribir ese tipo en una firma. Entonces, ¿cómo se pasa un closure a una función, o se devuelve de ella? Rust ofrece dos caminos, los mismos dos que rigen todo el polimorfismo del lenguaje: el estático —impl Fn y genéricos con bound Fn, que monomorfizan y se inlinan— y el dinámico —&dyn Fn, Box<dyn Fn>, que pasan por una vtable—. Elegir entre ellos es elegir entre velocidad y flexibilidad, y saber cuándo cada uno es la respuesta correcta separa el código idiomático del que pelea con el compilador.
- Aceptar closures como parámetro con genéricos,
impl Fny&dyn Fn, y saber qué elige cada forma. - Devolver closures con
-> impl Fny con-> Box<dyn Fn>, entendiendo por qué hace falta. - Comprender por qué el tipo anónimo de un closure obliga a estas construcciones.
- Sopesar el trade-off entre despacho estático (inlinado, monomorfizado) y dinámico (vtable, heterogéneo).
Recibir un closure: tres formas
Como el tipo del closure no se puede nombrar, lo describes por el trait que implementa. La vía estática usa un parámetro genérico con bound, o su azúcar impl Fn:
// Genérico explícito: F es un tipo concreto elegido en cada llamada.
fn aplica<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 { f(x) }
// Azúcar impl Trait: idéntico, más breve.
fn aplica2(f: impl Fn(i32) -> i32, x: i32) -> i32 { f(x) }
let doble = |n| n * 2;
println!("{}", aplica(doble, 21)); // 42
Ambas formas monomorfizan: por cada tipo concreto de closure que le pases, el compilador genera una copia especializada de aplica, con la llamada f(x) normalmente inlinada. Coste de abstracción: cero. La vía dinámica, en cambio, describe el closure tras un puntero a dyn Fn:
// &dyn Fn: presta el closure a traves de un trait object.
fn aplica_dyn(f: &dyn Fn(i32) -> i32, x: i32) -> i32 { f(x) }
let doble = |n| n * 2;
println!("{}", aplica_dyn(&doble, 21)); // 42
Aquí aplica_dyn se compila una sola vez; la llamada f(x) pasa por la vtable del closure (despacho dinámico, nivel 15). No se inlina, pero el binario no crece por cada closure distinto.
Guardar un closure: hace falta dyn
El caso donde el dinámico deja de ser una opción y pasa a ser una necesidad: almacenar closures de tipos distintos juntos, o guardarlos en una struct sin parametrizarla. Un Vec exige un único tipo, y dos closures distintos tienen tipos distintos:
// NO compila: cada closure tiene su propio tipo anonimo.
// let acciones = vec![|x| x + 1, |x| x * 2];
// Si compila: Box<dyn Fn> les da un tipo comun de tamano fijo.
let acciones: Vec<Box<dyn Fn(i32) -> i32>> = vec![
Box::new(|x| x + 1),
Box::new(|x| x * 2),
];
for a in &acciones {
println!("{}", a(10)); // 11, luego 20
}
Box<dyn Fn(i32) -> i32> borra el tipo concreto y lo aloja en el heap, dando a todos los closures el mismo tipo estático y el mismo tamaño —un puntero gordo—, requisito para meterlos en una colección homogénea. El mismo Box<dyn Fn> es lo que guardas en una struct cuando no quieres volverla genérica:
struct Boton {
al_pulsar: Box<dyn Fn()>, // callback poseído, tipo borrado
}
Devolver un closure
Devolver un closure choca con el mismo muro: no puedes escribir su tipo. Si la función devuelve siempre el mismo closure, -> impl Fn basta y es la opción de coste cero:
fn multiplicador(factor: i32) -> impl Fn(i32) -> i32 {
move |x| x * factor
}
let por3 = multiplicador(3);
println!("{}", por3(10)); // 30
impl Fn significa “devuelvo algún tipo concreto que implementa Fn, siempre el mismo, pero no te digo cuál”. El problema aparece cuando distintas ramas devuelven closures distintos: cada uno tiene su tipo, e impl Fn exige un tipo único. Ahí entra Box<dyn Fn>:
fn operacion(nombre: &str) -> Box<dyn Fn(i32) -> i32> {
match nombre {
"inc" => Box::new(|x| x + 1),
"doble" => Box::new(|x| x * 2),
_ => Box::new(|x| x),
}
}
Cada rama produce un closure de tipo diferente; solo tras borrarlos con dyn comparten un tipo de retorno común. Es el mismo patrón fábrica que viste con los trait objects, aplicado a funciones.
flowchart TB q1[Necesitas un solo tipo de closure] -->|si y quieres maxima velocidad| est[Estatico con impl Fn o generico] q1 -->|no varian los tipos o los guardas juntos| din[Dinamico con Box dyn Fn] est --> e1[Monomorfiza e inlina coste cero] est --> e2[Un tipo por llamada crece el binario] din --> d1[Una vtable y heap heterogeneo posible] din --> d2[Indireccion sin inlining] style est fill:#a6e3a1,color:#11111b style din fill:#89b4fa,color:#11111b
El trade-off
Las dos vías son las mismas del dispatch estático frente al dinámico, ahora sobre closures:
Estático: impl Fn / genéricos
Monomorfiza e inlina: velocidad máxima, cero indirección. A cambio, un solo tipo por sitio y algo de crecimiento del binario. Es el valor por defecto.
Dinámico: Box / &dyn Fn
Vtable y, si hace falta, heap. Permite tipos heterogéneos, guardarlos en colecciones y structs, y devolver distintos según la lógica. Cuesta una indirección y pierde el inlining.
La heurística: usa estático por defecto —impl Fn al recibir, impl Fn al devolver un tipo único— porque es gratis. Cambia a dinámico solo cuando lo necesitas de verdad: closures de tipos distintos en la misma colección, un callback guardado en una struct que no quieres parametrizar, o un retorno que varía por rama. No es “rápido contra lento”, es “homogéneo y monomorfizable” contra “heterogéneo y de tipo borrado”.
Fn, FnMut y FnOnce aparecen igual en las cuatro construcciones. Si vas a llamar el closure muchas veces mutando, usa impl FnMut o Box<dyn FnMut> y recibe el parámetro como mut. Si lo consumes una vez, impl FnOnce. La elección estático/dinámico y la elección del trait de llamada son decisiones ortogonales: combínalas según cuántas veces llamas y qué acceso necesitas.
Que un closure no tenga un tipo escribible parece una incomodidad, pero es una consecuencia inevitable —y reveladora— de cómo Rust representa las funciones con estado. Cada closure es una struct única que el compilador sintetiza a partir de su cuerpo y sus capturas: dos closures que hacen “lo mismo” tienen tipos distintos, porque son structs distintas, con campos distintos, de tamaños distintos. No hay un tipo Closure universal que puedas nombrar, igual que no hay un tipo “struct” universal. Esta imposibilidad te fuerza, cada vez que un closure cruza la frontera de una función, a declarar no el tipo sino la capacidad: no “esto es un tal-closure” sino “esto es algo que implementa Fn(i32) -> i32”. Y en ese acto de renombrar el tipo concreto por el trait, Rust te obliga a decidir conscientemente entre sus dos formas de polimorfismo, las mismas que atraviesan todo el lenguaje. impl Fn y los genéricos dicen “resuélvelo en compilación”: el compilador conoce el tipo exacto en cada sitio, genera código especializado y lo funde con el entorno de la llamada, de modo que la abstracción se evapora y solo queda un bucle apretado sin rastro de que hubo un closure. dyn Fn dice “resuélvelo en ejecución”: el tipo concreto se borra, el comportamiento sobrevive en una vtable, y a cambio de una indirección por llamada ganas lo que el estático no puede dar —meter closures de tipos irreconciliables en un mismo Vec, guardar un callback en una struct sin contagiarla de un parámetro genérico, devolver funciones distintas según una decisión que solo se conoce en tiempo de ejecución—. La elección no es de rendimiento sino de forma del problema: si tus closures son homogéneos y conocidos en el sitio, el estático los monomorfiza gratis; si son heterogéneos o su identidad se decide tarde, solo el borrado de tipo puede expresarlos. Así, la anonimia del tipo del closure no es un obstáculo que Rust no supo resolver, sino el mecanismo que te empuja, en cada firma, a hacer explícito qué contrato pides y qué estrategia de despacho eliges. El nombre que le falta al tipo te lo cobra el lenguaje en claridad de intención.
- Escribe
fn aplica_dos(f: impl Fn(i32) -> i32) -> i32que devuelvaf(1) + f(2), y pásale|x| x * x. - Construye un
Vec<Box<dyn Fn(i32) -> i32>>con tres closures distintos y aplícalos todos a un mismo número en un bucle. - Escribe
fn sumador(base: i32) -> impl Fn(i32) -> i32conmove, y explica por quéimpl Fnsirve aquí aunque no sirviera unVec. - Escribe
fn op(cual: bool) -> Box<dyn Fn(i32) -> i32>que devuelva un closure u otro según el booleano; razona por quéimpl Fnno compilaría. - Define una struct con un campo
Box<dyn FnMut()>, guarda en ella un closure que incremente un contador, y llámalo desde un método; observa que el campo debe recibirse como&mut.