Smart pointer en Rust: Box, Rc y Arc

1. Descripción general

Por defecto, cada valor en Rust tiene un único propietario, y Rust debe conocer el tamaño del valor en el momento de la compilación. Muchas situaciones del mundo real no cumplen estas dos condiciones: tipos de datos recursivos, listas que contienen múltiples tipos diferentes, o datos que necesitan múltiples propietarios. Este artículo presenta los tres smart pointer más comunes: Box<T>, Rc<T> y Arc<T>. Después de leerlo, sabrás qué tipo elegir en cada caso, cómo combinarlos con Weak, RefCell y Mutex, y qué errores evitar.

2. ¿Qué es un smart pointer?

Un smart pointer es una estructura que envuelve un puntero e implementa dos traits importantes:

  • Deref: te permite usar el smart pointer como una referencia ordinaria (*ptr, llamar métodos directamente).
  • Drop: libera automáticamente los recursos cuando el smart pointer sale del scope.

String y Vec<T> también son smart pointers. Box, Rc y Arc se diferencian en que se enfocan en cómo poseer datos en el heap. Los tres se encuentran en la librería estándar. No necesitas agregar ninguna dependencia a Cargo.toml.

3. Box<T>: un propietario, datos en el heap

3.1. Asignación básica

fn main() {
    let order_total = Box::new(250_000u64);
    println!("Total: {}", order_total);

    let doubled = *order_total * 2;
    println!("Doble: {}", doubled);
}

Box::new asigna el valor 250_000 en el heap. Solo el puntero permanece en el stack, con un tamaño fijo. println! funciona directamente porque Box implementa Display cuando T lo implementa. El operador * obtiene el valor interno gracias a Deref. Cuando order_total sale del scope de main, la memoria del heap se libera automáticamente.

Total: 250000
Doble: 500000

Envolver un u64 en Box como en el ejemplo anterior no ofrece ninguna ventaja. Este código solo ilustra la sintaxis. Box es realmente útil en los dos casos siguientes.

3.2. Tipos de datos recursivos

Supongamos que describes un proceso de tratamiento de pedidos como una serie de pasos:

#[derive(Debug)]
enum OrderStep {
    Done,
    Next(String, Box<OrderStep>),
}

use OrderStep::{Done, Next};

fn count(step: &OrderStep) -> usize {
    match step {
        Done => 0,
        Next(_, rest) => 1 + count(rest),
    }
}

fn main() {
    let flow = Next(
        "Realizar pedido".into(),
        Box::new(Next(
            "Pago".into(),
            Box::new(Next("Envío".into(), Box::new(Done))),
        )),
    );
    println!("Número de pasos: {}", count(&flow));
}

Si escribes Next(String, OrderStep) sin Box, el compilador reportará un error:

error[E0072]: recursive type `OrderStep` has infinite size

La razón es que un OrderStep contiene otro OrderStep, que a su vez contiene otro más, resultando en un tamaño infinito. Box<OrderStep> siempre tiene el tamaño de un puntero, por lo que el tamaño del enum se vuelve finito. En la función count, rest tiene tipo &Box<OrderStep>. Rust lo convierte automáticamente a &OrderStep gracias a deref coercion.

Número de pasos: 3

3.3. Trait object

Box<dyn Trait> te permite almacenar múltiples tipos diferentes en la misma colección:

trait PaymentMethod {
    fn pay(&self, amount: u64) -> String;
}

struct Cash;
struct Card {
    last4: String,
}

impl PaymentMethod for Cash {
    fn pay(&self, amount: u64) -> String {
        format!("Efectivo: {amount}")
    }
}

impl PaymentMethod for Card {
    fn pay(&self, amount: u64) -> String {
        format!("Tarjeta ****{}: {amount}", self.last4)
    }
}

fn main() {
    let methods: Vec<Box<dyn PaymentMethod>> = vec![
        Box::new(Cash),
        Box::new(Card { last4: "4242".into() }),
    ];
    for m in &methods {
        println!("{}", m.pay(100_000));
    }
}

Cash y Card tienen tamaños diferentes, por lo que no pueden colocarse directamente en el mismo Vec. Cuando se envuelven en Box<dyn PaymentMethod>, todos los elementos tienen el mismo tamaño (un fat pointer: puntero de datos más puntero de vtable). La llamada a pay se resuelve en tiempo de ejecución.

Efectivo: 100000
Tarjeta ****4242: 100000

Nota: Si todos los elementos tienen el mismo tipo, usa genéricos (impl PaymentMethod o <T: PaymentMethod>) en lugar de Box<dyn …>. Los genéricos utilizan static dispatch, no requieren asignación en heap y evitan las llamadas a través de vtable.

4. Rc<T>: múltiples propietarios en un thread

4.1. Conteo de referencias

Supongamos que múltiples Order pertenecen a un mismo Customer. No quieres copiar la información del cliente en cada pedido, y tampoco puedes determinar cuál vivirá más tiempo para ser el propietario. Este es el caso para usar Rc (Reference Counted):

use std::rc::Rc;

struct Customer {
    name: String,
}

struct Order {
    id: u32,
    customer: Rc<Customer>,
}

fn main() {
    let alice = Rc::new(Customer { name: "Alice".into() });
    println!("Después de crear: {}", Rc::strong_count(&alice));

    let o1 = Order { id: 1, customer: Rc::clone(&alice) };
    {
        let o2 = Order { id: 2, customer: Rc::clone(&alice) };
        println!("Dentro del bloque: {}", Rc::strong_count(&alice));
        println!("Pedido {} de {}", o2.id, o2.customer.name);
    }
    println!("Después del bloque: {}", Rc::strong_count(&alice));
    println!("Pedido {} de {}", o1.id, o1.customer.name);
}

Rc::clone no copia Customer. Solo incrementa el contador de strong count en 1. Cuando o2 se descarta al final del bloque, el contador se decrementa en 1. Customer solo se libera cuando el contador llega a 0.

Después de crear: 1
Dentro del bloque: 3
Pedido 2 de Alice
Después del bloque: 2
Pedido 1 de Alice

Por convención, es mejor escribir Rc::clone(&alice) en lugar de alice.clone(). Esta forma le indica al lector que se trata de una operación barata de incrementar el contador, no una copia profunda.

4.2. Modificar datos con Rc<RefCell<T>>

Rc<T> solo te proporciona referencias inmutables (&T). Si múltiples propietarios necesitan modificar los datos, combínalo con RefCell:

use std::cell::RefCell;
use std::rc::Rc;

struct Cart {
    items: Vec<String>,
}

fn main() {
    let cart = Rc::new(RefCell::new(Cart { items: vec![] }));

    let web = Rc::clone(&cart);
    let mobile = Rc::clone(&cart);

    web.borrow_mut().items.push("Camiseta".into());
    mobile.borrow_mut().items.push("Sombrero de palma".into());

    println!("{:?}", cart.borrow().items);
}

RefCell traslada la verificación de las reglas de borrow del tiempo de compilación al tiempo de ejecución. borrow_mut() devuelve un guard. Este guard expira al final de la declaración, por lo que la siguiente llamada sigue siendo válida.

["Camiseta", "Sombrero de palma"]

Nota: Si llamas a borrow_mut() mientras otro borrow() o borrow_mut() sigue vivo, el programa entrará en pánico con already borrowed: BorrowMutError. Mantén el guard el menor tiempo posible. Usa try_borrow_mut() si prefieres manejar el error en lugar de entrar en pánico.

4.3. Ciclos de referencias y Weak<T>

Si Customer mantiene una lista de Rc<Order> y cada Order mantiene Rc<Customer>, los dos contadores nunca llegarán a 0. El resultado es una fuga de memoria. Rust no puede prevenir esto en tiempo de compilación. La solución es usar Weak para una dirección de la referencia:

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Customer {
    name: String,
    orders: RefCell<Vec<Rc<Order>>>,
}

struct Order {
    id: u32,
    customer: Weak<Customer>,
}

fn main() {
    let bob = Rc::new(Customer {
        name: "Bob".into(),
        orders: RefCell::new(vec![]),
    });
    let order = Rc::new(Order { id: 7, customer: Rc::downgrade(&bob) });
    bob.orders.borrow_mut().push(Rc::clone(&order));

    if let Some(c) = order.customer.upgrade() {
        println!("Pedido {} pertenece a {}", order.id, c.name);
    }
    println!("strong={}, weak={}", Rc::strong_count(&bob), Rc::weak_count(&bob));

    drop(bob);
    println!("¿Queda cliente? {}", order.customer.upgrade().is_some());
}

Rc::downgrade crea un Weak. Weak solo incrementa el weak count y no mantiene los datos vivos. Cuando necesitas usarlo, llamas a upgrade() para obtener Option<Rc<T>>. El resultado es None si los datos ya fueron liberados. Después de drop(bob), el strong count de Customer llega a 0, por lo que Customer se libera, aunque el Weak en order aún exista.

Pedido 7 pertenece a Bob
strong=1, weak=1
¿Queda cliente? false

Regla general: usa Rc en la dirección “padre → hijo”, usa Weak en la dirección “hijo → padre”.

5. Arc<T>: múltiples propietarios entre múltiples threads

5.1. Por qué Rc no funciona con threads

Rc actualiza el contador con suma ordinaria, que no es segura cuando múltiples threads lo manipulan. Por eso, Rc no implementa el trait Send. Si intentas pasar Rc a thread::spawn, el compilador lo rechazará:

error[E0277]: `Rc<Customer>` cannot be sent between threads safely

Este es un punto fuerte de Rust: las condiciones de data race se previenen en tiempo de compilación. Arc (Atomically Reference Counted) tiene una API idéntica a Rc, pero utiliza operaciones atómicas para actualizar el contador.

5.2. Compartir datos de solo lectura

use std::sync::Arc;
use std::thread;

struct ShopConfig {
    currency: String,
    vat_percent: u64,
}

fn main() {
    let config = Arc::new(ShopConfig { currency: "VND".into(), vat_percent: 10 });

    let handles: Vec<_> = [100_000u64, 200_000]
        .into_iter()
        .map(|price| {
            let config = Arc::clone(&config);
            thread::spawn(move || {
                let vat = price * config.vat_percent / 100;
                println!("{} + IVA = {} {}", price, price + vat, config.currency);
            })
        })
        .collect();

    for h in handles {
        h.join().unwrap();
    }
}

Cada thread recibe su propia copia de Arc a través de move. Todas las copias apuntan al mismo ShopConfig. Los datos solo se leen, por lo que no se necesita un bloqueo. El orden de las dos líneas de salida puede variar entre ejecuciones.

100000 + IVA = 110000 VND
200000 + IVA = 220000 VND

5.3. Modificar datos con Arc<Mutex<T>>

RefCell no es seguro entre threads. El equivalente para múltiples threads es Mutex (o RwLock):

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let revenue = Arc::new(Mutex::new(0u64));
    let mut handles = Vec::new();

    for amount in [100u64, 250, 400] {
        let revenue = Arc::clone(&revenue);
        handles.push(thread::spawn(move || {
            *revenue.lock().unwrap() += amount;
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
    println!("Ingresos: {}", *revenue.lock().unwrap());
}

lock() devuelve un MutexGuard. El bloqueo se libera automáticamente cuando el guard se descarta, aquí al final de la declaración. unwrap() maneja el caso donde el mutex está envenenado, es decir, otro thread entró en pánico mientras tenía el bloqueo.

Ingresos: 750

Nota: Para un contador entero simple, Arc<AtomicU64> es más rápido que Arc<Mutex<u64>>. Además, no uses Arc donde solo hay un thread. Las operaciones atómicas son más costosas que la suma ordinaria, aunque la diferencia no es grande.

6. Escribir tests para el comportamiento del conteo de referencias

Rust tiene un framework de testing integrado, sin necesidad de crates adicionales. Coloca los siguientes tests al final de src/lib.rs o src/main.rs:

#[cfg(test)]
mod tests {
    use std::rc::{Rc, Weak};
    use std::sync::{Arc, Mutex};
    use std::thread;

    struct Customer {
        name: String,
    }

    #[test]
    fn rc_clone_increases_strong_count_and_drop_decreases_it() {
        let alice = Rc::new(Customer { name: "Alice".into() });
        let copy = Rc::clone(&alice);
        assert_eq!(Rc::strong_count(&alice), 2);
        assert!(Rc::ptr_eq(&alice, &copy));

        drop(copy);
        assert_eq!(Rc::strong_count(&alice), 1);
        assert_eq!(alice.name, "Alice");
    }

    #[test]
    fn weak_upgrade_returns_none_after_owner_dropped() {
        let bob = Rc::new(Customer { name: "Bob".into() });
        let weak: Weak<Customer> = Rc::downgrade(&bob);
        assert!(weak.upgrade().is_some());

        drop(bob);
        assert!(weak.upgrade().is_none());
    }

    #[test]
    fn arc_mutex_sums_correctly_across_threads() {
        let revenue = Arc::new(Mutex::new(0u64));
        let handles: Vec<_> = (1..=100)
            .map(|i| {
                let revenue = Arc::clone(&revenue);
                thread::spawn(move || *revenue.lock().unwrap() += i)
            })
            .collect();
        handles.into_iter().for_each(|h| h.join().unwrap());

        assert_eq!(*revenue.lock().unwrap(), 5050);
        assert_eq!(Arc::strong_count(&revenue), 1);
    }
}

El primer test verifica el contador y usa Rc::ptr_eq para confirmar que dos Rc apuntan a la misma memoria, es decir, sin copia profunda. El segundo test confirma que Weak no mantiene los datos vivos. El tercer test ejecuta 100 threads sumando valores, el resultado debe ser exactamente 5050. Después de que todos los threads se unan, solo queda una strong reference.

Ejecuta los tests:

cargo test
running 3 tests
test tests::rc_clone_increases_strong_count_and_drop_decreases_it ... ok
test tests::weak_upgrade_returns_none_after_owner_dropped ... ok
test tests::arc_mutex_sums_correctly_across_threads ... ok

test result: ok. 3 passed; 0 failed

7. ¿Qué tipo elegir?

Necesidad Opción
Un propietario, necesita heap (tipos recursivos, trait object, datos grandes que mover de forma económica) Box<T>
Múltiples propietarios, un thread, solo lectura Rc<T>
Múltiples propietarios, un thread, modificación requerida Rc<RefCell<T>>
Múltiples propietarios, múltiples threads, solo lectura Arc<T>
Múltiples propietarios, múltiples threads, modificación requerida Arc<Mutex<T>> / Arc<RwLock<T>>
Referencias inversas, caché, evitar ciclos Weak<T>

Algunos errores comunes:

  • Usar smart pointer cuando solo necesitas borrow. Si una función solo lee datos, recibe &Customer en lugar de Rc<Customer>. De esta forma la función es más flexible y no tienes que incrementar ni decrementar el contador.
  • Abuso de Rc<RefCell<T>>. El código lleno de este patrón suele indicar que estás trayendo mentalidad OOP de Java o C#. Considera reorganizar la ownership, o usa índices en Vec (arena pattern).
  • Ciclos de referencias. Tanto Rc como Arc pueden causar fugas de memoria cuando dos objetos se refieren mutuamente con strong references. Usa Weak para una dirección.
  • Mantener MutexGuard demasiado tiempo. Especialmente peligroso cuando lo mantienes a través de .await en código async, o cuando bloqueas múltiples mutexes en diferentes órdenes en threads diferentes, lo que causa deadlock.

8. Conclusión

Box<T> se usa cuando necesitas colocar datos en el heap con un único propietario, típicamente para tipos recursivos y trait objects. Rc<T> permite múltiples propietarios en un thread, mientras que Arc<T> hace lo mismo entre múltiples threads gracias a su contador atómico. Los smart pointers en sí solo permiten lectura. Cuando necesitas modificar datos, combínalos con RefCell (un thread) o Mutex/RwLock (múltiples threads), y usa Weak para evitar ciclos de referencias. Comienza con borrows ordinarios y solo cambia a smart pointers cuando las reglas de ownership predeterminadas realmente no funcionen.

rustsmart-pointerownershipconcurrencymemory