wandres.dev
SMART POINTERS · Box, Rc, Arc

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.

⏱ 18 min

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.

🎯 Al terminar esta lección sabrás
  • Alojar un valor en el heap con Box::new y entender su representación: un puntero fino.
  • Reconocer las tres situaciones donde Box es imprescindible: tipos recursivos, trait objects y valores grandes.
  • Explicar por qué un enum recursivo 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.

💡
Solo de un Box se puede sacar el valor

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.

ℹ️
La optimización de nicho

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.

Box es la respuesta única a dos preguntas que parecían distintas

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.

📝
Lo esencial de Box

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.

⚔️ Pon el valor en el heap
  1. Define enum Lista { Cons(i32, Box<Lista>), Nil } y construye la secuencia 1, 2, 3. Explica, contando bytes, por qué sin Box el tamaño sería infinito.
  2. Escribe un struct Nodo de árbol binario con hijos Option<Box<Nodo>> y crea un arbolito de tres nodos. ¿Por qué Option<Box<...>> y no Box<...> a secas?
  3. Escribe fn crear() -> Box<dyn std::fmt::Display> que devuelva a veces un i32 y a veces un String. Relaciónalo con por qué dyn Trait necesita un Box.
  4. Comprueba con std::mem::size_of que Option<Box<i32>> y Box<i32> miden lo mismo, y explica el porqué apelando al nicho.
  5. Argumenta por qué meter un i32 en un Box sin más es un despilfarro, y en qué se diferencia eso del caso recursivo, donde el Box es obligatorio.