Box de T: el valor que vive en el heap
`Box<T>` es el puntero inteligente más simple: un único dueño de una asignación en el heap. Cuándo hace falta de verdad —tipos recursivos que de otro modo tendrían tamaño infinito, trait objects que necesitan un tamaño conocido, valores grandes que no quieres mover byte a byte— y por qué su abstracción cuesta cero: un puntero fino, un `Deref` y un `Drop` que libera.
Box<T> es el más simple de los punteros inteligentes y el cimiento sobre el que se apoyan los demás: un único dueño de una asignación en el heap. No comparte, no cuenta referencias, no sincroniza; toma un valor, lo coloca en el montón y te entrega un puntero del tamaño de una palabra que lo posee. La pregunta que importa no es cómo se usa —es trivial— sino cuándo hace falta: casi nunca por capricho, casi siempre porque el tamaño del valor no puede conocerse en la pila. Tres situaciones lo vuelven imprescindible, y las tres son la misma frase dicha de tres maneras.
- Alojar un valor en el heap con
Box::newy entender su representación: un puntero fino. - Reconocer las tres situaciones donde
Boxes imprescindible: tipos recursivos, trait objects y valores grandes. - Explicar por qué un
enumrecursivo sin indirección tendría tamaño infinito. - Justificar por qué
Box<T>es una abstracción de coste cero.
Un dueño, un heap
Box::new(v) mueve v al heap y devuelve un Box<T> que lo posee en exclusiva. El Box implementa Deref hacia T —así que *b te da acceso al valor— y DerefMut si lo tienes mutable; al salir de scope, su Drop libera la asignación de forma determinista. Es un dueño como cualquier otro valor poseído: moverlo transfiere la propiedad del dato en el heap, y no existe forma de que dos Box apunten al mismo sitio (eso es territorio de Rc y Arc).
fn main() {
let en_heap: Box<i32> = Box::new(5);
println!("{en_heap}"); // Deref a &i32: imprime 5
let n = *en_heap + 1; // *en_heap desreferencia al i32 alojado
println!("{n}");
} // al salir de scope, el Drop de Box libera la memoria
Para un i32 esto es un despilfarro: nadie mete un entero en el heap sin motivo. Box gana sentido justo cuando poner el valor en la pila es imposible, no cuando es meramente caro. Su representación es un solo puntero no nulo hacia el dato: ni cabecera, ni contador, ni metadato adjunto.
El tamaño infinito: tipos recursivos
El caso canónico es un tipo que se contiene a sí mismo. Una lista enlazada ingenua no compila:
// NO compila: "recursive type `Lista` has infinite size"
// enum Lista {
// Cons(i32, Lista),
// Nil,
// }
El compilador calcula el tamaño de un enum como el de su variante mayor. Aquí Cons contiene un Lista, que contiene un Cons, que contiene un Lista… la recursión no tiene fondo y el tamaño diverge. La indirección lo corta: Box<Lista> es un puntero de tamaño fijo —una palabra— sea cual sea el tamaño de aquello a lo que apunta.
enum Lista {
Cons(i32, Box<Lista>),
Nil,
}
use Lista::{Cons, Nil};
fn main() {
let lista = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
if let Cons(cabeza, _) = &lista {
println!("cabeza = {cabeza}"); // cada Cons mide i32 mas un puntero
}
}
flowchart LR s[Pila: variable lista] --> c1[Cons 1 en heap] c1 -->|Box| c2[Cons 2 en heap] c2 -->|Box| c3[Cons 3 en heap] c3 -->|Box| n[Nil] style s fill:#89b4fa,color:#11111b style n fill:#a6e3a1,color:#11111b
El mismo argumento vale para un árbol: struct Nodo { valor: i32, izq: Option<Box<Nodo>>, der: Option<Box<Nodo>> }. Cada rama es un puntero de tamaño conocido, y el árbol entero se vuelve representable. Sin Box, ningún tipo autorreferente cabría en la pila.
Trait objects y valores grandes
Las otras dos situaciones son variantes de “el tamaño no se conoce en la pila”. Un trait object dyn Trait es ?Sized: no tiene tamaño conocido en compilación, así que no puede vivir suelto. Box<dyn Trait> le da un manejador de tamaño fijo —un puntero gordo con la vtable, como viste en el nivel 15— y por eso Box<dyn Error> o Vec<Box<dyn Forma>> son idiomáticos.
fn crear() -> Box<dyn std::fmt::Display> {
Box::new(String::from("un valor cualquiera")) // el tipo concreto se borra
}
El tercer caso es de rendimiento: mover un valor grande copia sus bytes de una posición de pila a otra, mientras que mover un Box copia solo el puntero. Y un enum con una variante enorme y varias diminutas malgasta memoria en cada valor pequeño; encajar la variante grande en un Box mantiene el enum compacto —la advertencia large_enum_variant de Clippy señala justamente esto—.
Coste cero
Box es el ejemplo de manual de abstracción de coste cero: su representación es un puntero, idéntico al que devolvería un malloc a mano en C; su Deref compila a una carga de memoria; su Drop, a un free. No hay contador de referencias, ni cabecera, ni trabajo en tiempo de ejecución más allá de la asignación que tú pediste explícitamente. Pagas por el heap, y por nada más.
Una consecuencia sutil de ser dueño único: puedes mover el valor fuera de un Box. La expresión let v = *b; saca el T del heap a la pila y libera la caja, porque b es el único dueño y nadie más se queda sin nada. Con Rc o Arc esto es imposible —*rc solo presta &T—, precisamente porque hay otros dueños a los que no puedes dejar colgados. Esta asimetría, fácil de pasar por alto, es una razón práctica más para preferir Box cuando de verdad hay un solo dueño: conservas la capacidad de recuperar el valor por movimiento.
Como un Box<T> nunca es nulo, el compilador usa el valor “puntero nulo” como nicho para representar None. Por eso Option<Box<T>> ocupa exactamente lo mismo que Box<T>: una palabra. El Some es el puntero real; el None, el patrón de bits todo a cero. No pagas ni un byte extra por envolver un Box en un Option, algo imposible con un puntero de C, donde NULL es un valor válido y ambiguo.
Detente en lo que comparten los tres casos. Un tipo recursivo, un trait object y una variante gigante parecen problemas distintos, pero el compilador los vive como el mismo problema: no sé cuánto mide esto en la pila. La recursión da un tamaño infinito; el dyn da un tamaño desconocido hasta ejecución; la variante enorme da un tamaño incómodamente grande. Y a las tres, Box contesta con el mismo gesto: interponer una indirección de tamaño fijo. Un puntero mide siempre una palabra, dé igual lo que haya al otro extremo —un byte, un gigabyte, un valor de tipo aún por decidir—, y esa uniformidad es lo que devuelve la calculabilidad al sizeof. Aquí se ve la economía profunda del diseño de Rust: no hay un tipo para cada problema, hay un mecanismo —poner el valor tras un puntero que posees— y el sistema de tipos lo aplica donde la pila se queda sin respuestas. Box no es una utilidad de conveniencia que a veces evita una copia; es la materialización de un principio: cuando el tamaño de un valor no cabe en el modelo de la pila —por infinito, por desconocido o por excesivo—, la indirección lo vuelve a hacer finito, conocido y manejable, y lo hace al precio mínimo que la física de la máquina permite: una carga y un puntero. Los punteros inteligentes que verás después —Rc, Arc, Weak— añaden capacidades sobre esta base, pero todos empiezan aquí, en la idea de que poseer algo en el heap puede costar tan poco como poseerlo en la pila.
Box<T> es un puntero de una palabra que posee un valor en el heap, con Deref hacia T y liberación determinista en su Drop. Es imprescindible cuando el tamaño no cabe en la pila: tipos recursivos (que de otro modo serían infinitos), trait objects dyn Trait (que son ?Sized) y valores grandes o variantes desproporcionadas. Es coste cero: representación de puntero crudo, sin contador ni cabecera, y con la optimización de nicho que hace Option<Box<T>> del mismo tamaño que Box<T>. Un solo dueño; para compartir, el siguiente paso es Rc.
- Define
enum Lista { Cons(i32, Box<Lista>), Nil }y construye la secuencia1, 2, 3. Explica, contando bytes, por qué sinBoxel tamaño sería infinito. - Escribe un
struct Nodode árbol binario con hijosOption<Box<Nodo>>y crea un arbolito de tres nodos. ¿Por quéOption<Box<...>>y noBox<...>a secas? - Escribe
fn crear() -> Box<dyn std::fmt::Display>que devuelva a veces uni32y a veces unString. Relaciónalo con por quédyn Traitnecesita unBox. - Comprueba con
std::mem::size_ofqueOption<Box<i32>>yBox<i32>miden lo mismo, y explica el porqué apelando al nicho. - Argumenta por qué meter un
i32en unBoxsin más es un despilfarro, y en qué se diferencia eso del caso recursivo, donde elBoxes obligatorio.