rust-smart-pointers-box

Box<T> en Rust: heap y tipos recursivos paso a paso

  • 4 min

Box<T> es un puntero de propiedad única hacia el heap que almacena un valor.

En los primeros artículos aprendimos la diferencia entre Stack (rápido, tamaño fijo, ordenado) y Heap (flexible, tamaño dinámico, desordenado).

Por defecto, los tipos primitivos y los structs en Rust se almacenan en el Stack. Pero, ¿qué pasa si tenemos un dato gigante que no queremos copiar constantemente? ¿O si queremos crear una estructura de datos que se contiene a sí misma (como una lista enlazada o un árbol)?

Para eso existe Box.

¿Qué es Box<T>?

Box<T> es el Smart Pointer más sencillo.

  • Función: Permite almacenar un dato en el Heap en lugar del Stack.
  • Representación: Para un T de tamaño conocido, Box<T> contiene un puntero de tamaño fijo hacia la reserva.
  • Propiedad: Box es el dueño único del dato. Cuando la caja sale del ámbito, destruye el valor y libera su reserva del heap.

Uso básico

fn main() {
    // El '5' se guarda en el Heap. 'b' es un puntero en el Stack.
    let b = Box::new(5);

    println!("b = {}", b);
} // Aquí 'b' sale del scope y Rust libera la memoria del Heap automáticamente.
Copied!

A efectos prácticos, se usa casi igual que el valor original. Rust aplica “desreferencia automática” cuando llamas a métodos, pero si quieres acceder al valor crudo manualmente, puedes usar el operador de desreferencia *.

let x = 5;
let b = Box::new(x); // Copia x al Heap

assert_eq!(5, x);
assert_eq!(5, *b); // *b accede al valor dentro de la caja
Copied!

¿Cuándo usar Box?

No deberías usar Box para un simple número entero (es más lento que usar el Stack). Sus casos de uso reales son:

  1. Valores grandes o cuya dirección debe mantenerse estable: Mover un Box<T> mueve el puntero, no el contenido reservado.
  2. Objetos de trait: Cuando quieres guardar tras un puntero un tipo concreto elegido en tiempo de ejecución, como Box<dyn Animal>.
  3. Tipos recursivos: El caso que vamos a ver ahora.

El problema de los tipos recursivos

Rust necesita saber en tiempo de compilación cuánto espacio exacto ocupa un tipo de dato.

Imagina que queremos crear una Lista Enlazada (Cons List) al estilo Lisp. Una lista es:

  • O bien un valor y el resto de la lista.
  • O bien Nil (fin de la lista).
// ❌ ESTO NO COMPILA
enum Lista {
    Cons(i32, Lista),
    Nil,
}
Copied!

Rust intentará calcular el tamaño de Lista:

  1. ¿Cuánto ocupa Lista? Depende de sus variantes.
  2. Cons guarda un i32 y… ¡otra Lista!
  3. Entonces size(Lista) = size(i32) + size(Lista).
  4. size(Lista) = size(i32) + size(i32) + size(Lista)
  5. Resultado: Tamaño infinito.

El compilador te dará el error: recursive type Lista has infinite size.

La solución: indirección con Box

No podemos meter una lista infinita dentro de otra. Pero sí podemos meter un puntero a la siguiente lista.

Sabemos cuánto ocupa un puntero fino como Box<Lista>: un tamaño fijo para cada arquitectura (habitualmente 8 bytes en sistemas de 64 bits).

Si envolvemos la recursión en un Box, rompemos el ciclo infinito.

// ✅ ESTO SÍ COMPILA
enum Lista {
    Cons(i32, Box<Lista>), // Ahora es i32 + puntero
    Nil,
}

use crate::Lista::{Cons, Nil};

fn main() {
    // Lista: 1 -> 2 -> 3 -> Nil
    let lista = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Copied!

Ahora el cálculo del tamaño es: size(Lista) = size(i32) + size(Box). Finito y conocido.

Visualmente, en lugar de tener objetos anidados como matrioskas (que no cabrían en memoria), tenemos objetos separados en el Heap, y cada uno tiene un “papelito” (puntero) que dice dónde está el siguiente.

Desempaquetando la caja

Para trabajar con datos dentro de un Box (por ejemplo, en un match), necesitamos tener cuidado con el Ownership. Box es dueño del dato, por lo que no podemos simplemente sacarlo sin romper la caja (a menos que lo movamos).

fn imprimir_lista(lista: Lista) {
    match lista {
        Cons(valor, siguiente_caja) => {
            println!("Valor: {}", valor);
            // 'siguiente_caja' es un Box<Lista>.
            // Podemos llamarnos recursivamente pasando el contenido del Box.
            imprimir_lista(*siguiente_caja);
        },
        Nil => println!("Fin"),
    }
}
Copied!

Nota: Al usar *siguiente_caja estamos moviendo (Move) el contenido fuera de la caja hacia la llamada recursiva. La caja original se destruye.