wandres.dev
INTERIOR MUTABILITY · Cell, RefCell

`Cell<T>`: mutar moviendo valores, sin prestar referencias

`Cell<T>` es la mutabilidad interior en su forma más pura y barata: nunca entrega una referencia a su contenido, solo mueve valores enteros dentro y fuera con `get` y `set`. Al no existir referencias al interior, no hay aliasing que vigilar, así que no hace falta ningún chequeo en ejecución: `Cell<T>` ocupa exactamente lo que `T` y su coste es cero. Ideal para tipos `Copy` —contadores, banderas, pequeños estados— compartidos a través de `&self`.

⏱ 17 min

Si el problema era mutar tras un & compartido, Cell<T> lo resuelve por la vía más radical imaginable: no prestando nunca una referencia al interior. No puedes obtener un &mut al valor guardado, ni siquiera un &; solo puedes sacar una copia con get o meter un valor nuevo con set. Y como no hay ninguna referencia apuntando dentro de la Cell, no existe aliasing que pueda entrar en conflicto con la escritura: mutar es trivialmente seguro porque no hay nadie a quien sorprender. Ese es el trato de Cell: renuncias a las referencias y a cambio obtienes mutación libre, sin comprobación alguna, a coste cero.

🎯 Al terminar esta lección sabrás
  • Usar Cell<T> con new, get, set, replace y take.
  • Explicar por qué get exige T: Copy y por qué eso hace seguro el diseño.
  • Mutar campos a través de &self guardándolos en un Cell.
  • Justificar por qué Cell<T> no tiene coste en tiempo de ejecución ni en memoria.

La interfaz que nunca presta

Cell<T> es una caja con una regla de oro: el contenido entra y sale por valor, jamás por referencia. Su API refleja esa disciplina.

use std::cell::Cell;

let c = Cell::new(5);

c.set(10);                       // sobrescribe: mete un valor nuevo
let x = c.get();                 // saca una COPIA del valor actual -> 10
let viejo = c.replace(20);       // devuelve el anterior (10) y guarda 20
let n = c.take();                // deja Default::default() y devuelve lo que habia (20)
assert_eq!((x, viejo, n), (10, 10, 20));
assert_eq!(c.get(), 0);          // take dejo el 0 por defecto

Cuatro operaciones y ni una entrega un & al interior. get exige T: Copy porque saca el valor duplicándolo: si no fuera copiable, tendría que moverlo fuera —dejando la celda vacía— o prestarlo —lo que rompería la regla—. Para tipos que no son Copy, usas replace (das uno a cambio), take (dejas el Default) o set (descartas el previo), pero nunca get.

Mutar a través de &self

Aquí Cell cumple su promesa: convierte un método &self en un método que puede mutar. Retomemos el contador del nivel anterior, ahora funcional:

use std::cell::Cell;

struct Pagina {
    visitas: Cell<u32>,
}

impl Pagina {
    fn ver(&self) {                          // &self, no &mut self
        let previas = self.visitas.get();
        self.visitas.set(previas + 1);       // muta tras una referencia compartida
    }
}

fn main() {
    let p = Pagina { visitas: Cell::new(0) };
    p.ver();
    p.ver();
    p.ver();
    assert_eq!(p.visitas.get(), 3);
}

ver toma &self y aun así incrementa el contador. Nada en el tipo de p delata que hay mutación; la firma sigue prometiendo “no te muevo el objeto”, y es cierta: lo que cambia es un dato interior cuya seguridad garantiza Cell, no el borrow checker. Para el llamador, p sigue siendo compartible con toda tranquilidad.

flowchart LR
fuera[Valor fuera] -->|set mete una copia| dentro[Valor dentro de la Cell]
dentro -->|get saca una copia| copia[Copia independiente fuera]
dentro -->|replace guarda el nuevo y devuelve el viejo| copia
style dentro fill:#89b4fa,color:#11111b
style copia fill:#a6e3a1,color:#11111b

Coste cero: qué paga y qué no

La consecuencia más notable de no prestar referencias es que Cell no necesita llevar ninguna contabilidad. RefCell, que sí presta, tiene que guardar un contador de préstamos; Cell no guarda nada extra. Es repr(transparent) sobre su primitiva interna, de modo que su tamaño y su alineación son idénticos a los de T:

use std::cell::Cell;
use std::mem::size_of;

assert_eq!(size_of::<Cell<u32>>(), size_of::<u32>());   // sin bytes de mas
assert_eq!(size_of::<Cell<[u8; 16]>>(), 16);            // exactamente el contenido

En el binario, get y set compilan a una lectura y una escritura de memoria corrientes: no hay ramas, ni bloqueos, ni banderas que consultar. Cell es, literalmente, mutación sin fricción. Cuando &mut self te viene grande solo porque necesitas tocar un u32 o un bool, Cell es la herramienta exacta.

Más allá del contador

Cell no se limita a enteros sueltos. Cualquier tipo Copy cabe dentro, y eso incluye un Option<T> copiable, con el que se arma una memoización sencilla: calcular una vez, guardar, devolver la copia después.

use std::cell::Cell;

struct Circulo {
    radio: f64,
    area_cache: Cell<Option<f64>>,
}

impl Circulo {
    fn area(&self) -> f64 {                       // &self, y aun asi cachea
        if let Some(a) = self.area_cache.get() {
            return a;                             // ya calculada antes
        }
        let a = std::f64::consts::PI * self.radio * self.radio;
        self.area_cache.set(Some(a));            // memoiza tras un &
        a
    }
}

Como Option<f64> es Copy, get y set bastan y el patrón conserva su coste mínimo. El límite reaparece en cuanto el resultado a cachear no sea Copy —un String, un Vec—: entonces get deja de servir, porque tendría que mover o prestar, y el trabajo pasa a RefCell, la siguiente puerta.

💡
get_mut cuando sí eres el único

Si en algún punto tienes acceso &mut a la Cell —porque eres su dueño exclusivo—, get_mut te devuelve un &mut T directo al interior, sin copiar. Es coherente con todo el modelo: teniendo exclusividad, el borrow checker estático ya garantiza la ausencia de aliasing, así que prestar es seguro y no hace falta la disciplina de Cell. La mutabilidad interior solo aporta algo cuando el acceso es compartido.

⚠️
Cell no cruza hilos

Cell<T> implementa Send si T: Send, pero nunca Sync: no puedes compartir un &Cell<T> entre hilos. Es deliberado. get y set no son atómicos; dos hilos mutando la misma celda a la vez serían una carrera de datos, exactamente lo que Rust promete impedir. Para estado compartido y mutable entre hilos existen Mutex, RwLock y los tipos atómicos, que verás más adelante. Cell es, por diseño, una herramienta de un solo hilo.

La forma más pura de seguridad: eliminar la posibilidad del peligro, no vigilarla

Cell encierra una lección de diseño que trasciende a Rust. Frente a una operación peligrosa —mutar un dato que otros podrían estar observando— hay dos filosofías. La primera, vigilar: permitir referencias al interior y montar un mecanismo que detecte y prohíba los conflictos; es lo que hará RefCell con su contador y su panic, y lo que hace el borrow checker en compilación. La segunda, abolir: quitar de la ecuación aquello que hace peligrosa la operación, de modo que ya no haya nada que vigilar. Cell elige la segunda. Al no entregar jamás una referencia al interior, elimina el aliasing de raíz: si nadie puede tener un & apuntando dentro de la celda, entonces sobrescribir su contenido no puede pisar la lectura de nadie, porque no hay lecturas en curso a través de referencias. La escritura es segura no porque un guardián la apruebe, sino porque la clase entera de errores que la harían insegura no puede expresarse. El precio de esta pureza es igualmente nítido y hay que nombrarlo: pierdes la capacidad de prestar el interior, y con ella la de mutar en el sitio un valor grande o de leer un campo sin copiarlo entero; por eso Cell brilla con tipos Copy pequeños —contadores, banderas, índices— y se vuelve incómoda con estructuras voluminosas. Pero dentro de su dominio no tiene rival: es mutabilidad interior sin ningún coste en ejecución, sin ningún byte extra, sin ningún riesgo de panic, porque no hay nada que comprobar. La próxima vez que dudes entre Cell y RefCell, la pregunta no es “cuál es más potente”, sino “¿necesito prestar el interior?”. Si la respuesta es no, Cell te da la seguridad más barata que existe: la que nace de haber hecho imposible el error, no de haberlo atrapado a tiempo.

📝
Lo esencial de Cell

Cell<T> da mutabilidad interior sin prestar referencias: mueves valores con set, replace, take y —si T: Copyget. Al no existir referencias al interior, no hay aliasing que vigilar, así que no lleva contador ni chequeos: es repr(transparent), ocupa lo mismo que T y compila a lecturas y escrituras corrientes, coste cero. Sirve para mutar campos pequeños y Copy a través de &self. Es !Sync: nunca cruza hilos. Su límite es que no puedes prestar ni mutar en el sitio; para eso está RefCell.

⚔️ Muta sin prestar
  1. Crea una Cell::new(5), encadena set, get, replace y take, y predice el valor tras cada paso antes de ejecutarlo.
  2. Escribe struct Pagina { visitas: Cell<u32> } con fn ver(&self) que incremente el contador. Comprueba que puedes llamarlo sobre un &Pagina.
  3. Intenta llamar a get sobre una Cell<String> y explica el error; luego consigue el mismo efecto sin violar la regla usando replace o take.
  4. Verifica con size_of que Cell<[u8; 32]> mide 32 bytes y razona por qué no hay bytes de contabilidad.
  5. Explica por qué Cell<T> es !Sync y qué desastre concreto evitaría el compilador al impedir compartir un &Cell<T> entre dos hilos.