wandres.dev
SMART POINTERS · Box, Rc, Arc

Rc de T: propiedad compartida por conteo de referencias

`Box` tiene un único dueño; pero a veces un mismo dato necesita varios. `Rc<T>` (reference counted) reparte la propiedad de un valor entre muchos dueños: `clone` no copia el dato, incrementa un contador; el valor muere cuando cae el último dueño. Su representación con contadores junto al dato, por qué solo da acceso compartido y por qué no es apto para hilos.

⏱ 19 min

Box impone un dueño único, y casi siempre es lo correcto. Pero algunas estructuras piden que un mismo dato tenga varios dueños: un nodo de un grafo al que apuntan varios padres, una configuración que media docena de componentes comparten, la cola compartida de dos listas. Ahí la propiedad exclusiva estorba. Rc<T>reference counted, contado por referencias— reparte la propiedad: cada dueño posee una participación, clone no duplica el dato sino que suma uno a un contador, y el valor vive hasta que la última participación se libera. Es propiedad compartida deterministas, sin recolector de basura y sin coste de fondo. El precio: un solo hilo.

🎯 Al terminar esta lección sabrás
  • Compartir la propiedad de un mismo valor con Rc::new y Rc::clone.
  • Entender el conteo fuerte: clone incrementa, drop decrementa, y en cero se libera.
  • Ver la representación en memoria: los contadores viven junto al dato en una sola asignación.
  • Reconocer por qué Rc solo da acceso compartido y por qué no implementa Send ni Sync.

Varios dueños para un mismo dato

Con Box no puedes construir dos listas que compartan una misma cola: mover la cola a la primera la deja indisponible para la segunda, y el borrow checker lo rechaza. Rc disuelve el problema dando a cada uno su propia participación sobre el mismo dato:

use std::rc::Rc;

enum Lista {
    Cons(i32, Rc<Lista>),
    Nil,
}
use Lista::{Cons, Nil};

fn main() {
    let compartida = Rc::new(Cons(10, Rc::new(Nil)));
    let a = Cons(3, Rc::clone(&compartida));   // a apunta a la cola compartida
    let b = Cons(4, Rc::clone(&compartida));   // b tambien, sin copiarla
}

Rc::clone(&compartida) no clona en profundidad el Cons(10, ...): fabrica otro Rc que apunta al mismo sitio y suma uno al contador. Es una operación de coste constante, no proporcional al tamaño del dato. La convención de escribir Rc::clone(&x) en vez de x.clone() es deliberada: deja a la vista que es un incremento barato de contador y no una copia costosa.

El conteo de referencias

La mecánica es un contador que sube con cada clone y baja con cada drop; al llegar a cero, el valor y su asignación se liberan. Rc::strong_count te deja observarlo:

use std::rc::Rc;

fn main() {
    let a = Rc::new(String::from("dato"));
    println!("cuenta = {}", Rc::strong_count(&a));   // 1
    let _b = Rc::clone(&a);
    println!("cuenta = {}", Rc::strong_count(&a));   // 2
    {
        let _c = Rc::clone(&a);
        println!("cuenta = {}", Rc::strong_count(&a)); // 3
    } // _c sale de scope: su Drop baja la cuenta a 2
    println!("cuenta = {}", Rc::strong_count(&a));    // 2
} // aqui caen _b y a: la cuenta llega a 0 y la String se libera

No hay que llamar a nada manualmente: el Drop de cada Rc decrementa por ti, y el último en caer ejecuta la destrucción del valor. Es la misma liberación determinista de Box, solo que compartida entre varios puntos del programa que dejan de existir en momentos distintos.

La representación en memoria

Un Rc<T> apunta a una asignación interna —el RcBox— que guarda, contiguos, el contador fuerte, el contador débil (lo verás con Weak) y el valor T. Los contadores viven pegados al dato, en una única asignación; el Rc<T> en sí es una sola palabra: un puntero a esa caja. Por eso todos los clones comparten el mismo contador —apuntan a la misma caja— y por eso clonar es tan barato.

flowchart TB
a[Rc a en pila] --> caja
b[Rc b en pila] --> caja
c[Rc c en pila] --> caja
subgraph caja[RcBox en el heap]
  sc[contador fuerte 3]
  wc[contador debil 0]
  val[valor T]
end
style a fill:#89b4fa,color:#11111b
style b fill:#89b4fa,color:#11111b
style c fill:#89b4fa,color:#11111b
style val fill:#a6e3a1,color:#11111b

Compartido no es mutable

Rc<T> implementa Deref hacia T pero no DerefMut: solo obtienes &T, nunca &mut T. Es forzoso: si varios dueños comparten el dato y cualquiera pudiera pedir &mut, tendrías aliasing mutable —dos referencias mutables vivas al mismo valor— que es exactamente lo que el borrow checker prohíbe. Para mutar un dato compartido necesitas mutabilidad interior: envolver el contenido en un RefCell, dando Rc<RefCell<T>>, que traslada la comprobación de préstamos a tiempo de ejecución. Lo montarás en la última lección del nivel.

Hay una grieta segura: Rc::get_mut devuelve Some(&mut T) solo si la cuenta fuerte es 1 y no hay débiles, es decir, si de hecho no compartes con nadie en ese instante. Si hay más dueños, devuelve None, porque conceder mutabilidad rompería a los demás.

Por qué Rc no cruza hilos

El contador de Rc es un entero normal, incrementado y decrementado sin sincronización. Es rapidísimo, pero frágil ante la concurrencia: si dos hilos clonaran o soltaran el mismo Rc a la vez, las dos operaciones sobre el contador podrían entrelazarse y perder una cuenta, provocando una doble liberación o una fuga. Rust lo impide de raíz: Rc<T> es !Send y !Sync, así que el compilador rechaza moverlo o compartirlo entre hilos. No es una recomendación, es un error de compilación. Para compartir entre hilos existe Arc, la siguiente lección, que paga contadores atómicos a cambio de esa seguridad.

💡
Rc::clone frente a x.clone()

Ambos hacen lo mismo, pero escribe Rc::clone(&x). La forma explícita comunica al lector que es un incremento de contador de coste constante, no una copia profunda del dato. Cuando más tarde busques cuellos de botella, los Rc::clone son ruido inocente; los .clone() de verdad —los que duplican Vec o String— son los sospechosos. Mezclarlos bajo el mismo .clone() te esconde esa distinción.

El conteo de referencias es recolección de basura descentralizada y determinista

Rc parece un truco pequeño —un contador— pero es una filosofía entera sobre cuándo muere un dato. Un recolector de basura por rastreo responde a esa pregunta de forma global y diferida: cada cierto tiempo detiene el mundo, recorre el grafo de objetos alcanzables y libera el resto, en un momento que tú no eliges y con una pausa que tú no controlas. El conteo de referencias responde de forma local e inmediata: cada dueño sabe únicamente que sostiene una participación, y la última en soltarse —la que lleva el contador a cero— destruye el valor ahí mismo, en la instrucción exacta en que ese Rc cae. Nadie tiene la vista global; la corrección emerge de una regla puramente local repetida en cada clone y cada drop. Esto compra dos cosas que un GC no da: determinismo —sabes que el destructor corre justo cuando muere el último dueño, no cuando al recolector le apetezca— y coste distribuido —no hay hilo de fondo ni pausas, solo una suma o una resta por operación—. Y compra la elección de no pagar por lo que no usas: como el contador de Rc es un entero corriente, no un atómico, compartir dentro de un hilo cuesta lo mínimo posible; el precio de la sincronización se paga solo cuando de verdad cruzas hilos, y entonces cambias a Arc. Que esa frontera esté vigilada por el sistema de tipos —Rc es !Send, punto— significa que el compromiso “rápido pero de un hilo” no es un pacto de caballeros que puedas romper por descuido, sino un teorema que el compilador te demuestra. Rc no es solo memoria compartida: es la afirmación de que la gestión de memoria puede ser precisa, local y gratuita cuando el problema es local.

📝
Lo esencial de Rc

Rc<T> reparte la propiedad de un valor por conteo de referencias, en un solo hilo. Rc::clone incrementa el contador —coste constante, no copia el dato— y cada drop lo decrementa; al llegar a cero, el valor se libera de forma determinista. Los dos contadores viven en el RcBox, junto al dato, en una única asignación; el Rc en sí es un puntero de una palabra. Solo entrega &T: para mutar lo compartido necesitas Rc<RefCell<T>>. Es !Send y !Sync por su contador no atómico; para cruzar hilos existe Arc.

⚔️ Comparte y cuenta
  1. Construye tres listas a, b y c que compartan una misma cola con Rc, e imprime Rc::strong_count de la cola después de cada clone.
  2. Envuelve un String en un Rc, clónalo dentro de un bloque anidado y observa cómo la cuenta sube y baja al entrar y salir del scope. Explica quién ejecuta cada decremento.
  3. Intenta obtener &mut del valor con *rc = ... y lee el error. Después usa Rc::get_mut con cuenta 1 y con cuenta 2, y explica por qué solo funciona en el primer caso.
  4. Intenta mover un Rc a un thread::spawn y lee el mensaje sobre Send. Relaciónalo con la naturaleza no atómica del contador.
  5. Dibuja el RcBox en memoria para una cuenta de 3: sitúa los dos contadores y el valor, y explica por qué todos los clones ven el mismo contador.