wandres.dev
INTERIOR MUTABILITY · Cell, RefCell

El problema: mutar a través de una referencia compartida

El borrow checker impone una ley de hierro: compartir O mutar, nunca a la vez. Un `&T` deja leer pero no escribir; un `&mut T` deja escribir pero exige ser la única referencia viva. Y sin embargo hay código legítimo que necesita mutar a través de un `&` compartido: el recuento de un `Rc` que sube al clonarlo, una caché que se rellena en el primer acceso, un contador escondido en un método `&self`. La mutabilidad interior es la vía de escape controlada a esa ley.

⏱ 18 min

La regla que gobierna todo el modelo de préstamos de Rust cabe en cinco palabras: compartir o mutar, nunca ambas. A través de un &T puedes leer cuanto quieras, pero no escribir; a través de un &mut T puedes escribir, pero el compilador exige que sea la única referencia viva a ese dato. Esa disyunción —aliasing XOR mutación— es lo que borra de raíz las carreras de datos y los punteros colgantes. Pero es una regla, y las reglas útiles tienen excepciones legítimas. A veces necesitas de verdad mutar un dato mientras varias referencias compartidas lo observan: el recuento de un Rc que se incrementa al clonarlo con &self, una caché perezosa que se rellena en el primer acceso, un contador de visitas dentro de un método &self. La mutabilidad interior es el mecanismo con el que Rust concede esa excepción sin renunciar a la seguridad.

🎯 Al terminar esta lección sabrás
  • Enunciar la regla compartir-o-mutar y por qué prohíbe escribir a través de &T.
  • Reconocer los casos legítimos que exigen mutar tras una referencia compartida.
  • Distinguir la mutabilidad heredada de la mutabilidad interior.
  • Situar Cell, RefCell y UnsafeCell como las tres puertas a ese patrón.

La ley de hierro del borrow checker

En Rust la mutabilidad no es una propiedad del dato, sino del camino de acceso por el que lo alcanzas. Un valor es mutable solo si llegas a él a través de un dueño con binding mut o de una referencia &mut. Esto se llama mutabilidad heredada: la capacidad de escribir se hereda del camino, no reside en la variable. Y de ahí sale la prohibición tajante: si solo tienes un &T, no puedes mutar, y el compilador lo rechaza sin contemplaciones.

struct Contador {
    visitas: u32,
}

impl Contador {
    fn registrar(&self) {
        // self.visitas += 1;   // ERROR: cannot assign, `self` es un `&Contador`
    }
}

&self es azúcar de self: &Contador: una referencia compartida. Escribir a través de ella violaría la regla, porque nada garantiza que no exista otra referencia leyendo ese mismo campo en el mismo instante. El compilador no distingue “aquí no hay nadie más”: para poder demostrarlo estáticamente, prohíbe el caso entero.

Cuándo la regla estorba

El problema es que hay diseños perfectamente correctos que la regla veta. El más revelador vive en la propia biblioteca estándar: Rc<T>, el puntero de propiedad compartida, lleva dentro un recuento de referencias que debe subir al clonar y bajar al soltar. Pero clonar un Rc toma &self —tiene que ser así: clonar es compartir, no consumir—, y aun así muta ese recuento:

use std::rc::Rc;

let a = Rc::new(String::from("dato"));
let b = Rc::clone(&a);                    // &a es compartido...
println!("{}", Rc::strong_count(&a));     // ...y el recuento ya vale 2
drop(b);
println!("{}", Rc::strong_count(&a));     // 1 otra vez

Sin mutar tras un &, Rc no podría existir. Y no está solo: una caché perezosa que se calcula en el primer plan(&self), un objeto mock que registra cuántas veces lo llamaron en un test, un nodo de grafo con aristas que apuntan a vecinos compartidos, el patrón observador que notifica a suscriptores… todos necesitan escribir a través de referencias compartidas.

struct Consulta {
    sql: String,
    plan: Option<String>,          // se calcularia una vez y se reutilizaria
}

impl Consulta {
    fn plan(&self) -> &str {
        // if self.plan.is_none() {
        //     self.plan = Some(compilar(&self.sql));   // ERROR: `self` es `&Consulta`
        // }
        self.plan.as_deref().unwrap_or("")
    }
}

La regla, tomada al pie de la letra, declararía imposible esta caché tan corriente: plan toma &self porque consultar no debería exigir posesión exclusiva, y sin embargo querría rellenar el campo la primera vez. La mutabilidad interior es justo lo que desatasca este patrón.

Dos mundos: heredada frente a interior

🔒

Mutabilidad heredada

La norma. Mutas si posees el valor o lo alcanzas por un &mut. El compilador la verifica estáticamente: coste cero, ninguna sorpresa en ejecución.

🔓

Mutabilidad interior

La excepción. Un tipo envoltorio te deja mutar su contenido a través de un & compartido y garantiza la seguridad por otra vía: sin dar referencias (Cell) o con un chequeo en ejecución (RefCell).

La mutabilidad interior no rompe la regla: la sustituye por una garantía equivalente aplicada de otra manera. En vez de que el compilador demuestre en tiempo de compilación que no hay aliasing peligroso, es el propio tipo el que se encarga de que la mutación sea segura —moviendo valores enteros en lugar de prestar referencias, o llevando un registro de préstamos que consulta en cada acceso.

Las tres puertas

Rust ofrece tres tipos para entrar en este territorio, de mayor a menor abstracción:

  • Cell<T> — mueve valores dentro y fuera con get y set, sin entregar jamás una referencia al interior. Seguro por ausencia de referencias. Coste cero.
  • RefCell<T> — presta referencias con borrow y borrow_mut, pero comprueba la regla compartir-o-mutar en tiempo de ejecución y hace panic si la violas.
  • UnsafeCell<T> — la primitiva cruda del lenguaje sobre la que se construyen las dos anteriores; la única puerta que el compilador acepta para mutar tras un &, y por dentro requiere unsafe.
flowchart TB
regla[Compartir o mutar nunca ambas] --> choque[Necesito mutar tras una referencia compartida]
choque --> cell[Cell mueve valores sin dar referencias]
choque --> refcell[RefCell presta y comprueba en ejecucion]
choque --> unsafe[UnsafeCell la primitiva cruda]
cell --> seguro[API segura por fuera]
refcell --> seguro
unsafe --> nucleo[Nucleo unsafe por dentro]
nucleo --> seguro
style regla fill:#f38ba8,color:#11111b
style seguro fill:#a6e3a1,color:#11111b
style unsafe fill:#89b4fa,color:#11111b

Elegir la puerta correcta es cosa de preguntarse qué necesitas hacer con el interior, en este orden:

  • Si el dato es Copy y pequeño —un contador, una bandera, un índice— y te basta leerlo y sobrescribirlo entero: Cell<T>. Es la más barata y no puede hacer panic.
  • Si necesitas prestar el interior —mutar en el sitio un Vec, invocar métodos &mut sobre él, leer sin copiar—: RefCell<T>, asumiendo el chequeo en ejecución y el riesgo de panic.
  • Si escribes tú una abstracción de mutabilidad interior nueva y ninguna de las anteriores encaja: UnsafeCell<T>, con la responsabilidad de demostrar la seguridad a mano.

Y una regla transversal: si el estado se comparte entre hilos, ninguna de las tres sirve directamente —son todas !Sync—; ahí entran Mutex, RwLock y los tipos atómicos, que cierran el patrón más adelante.

ℹ️
Interior mutability no es datos mutables globales

No confundas mutabilidad interior con las variables globales mutables de C. Aquí no hay estado ambiental oculto: la mutación sigue localizada dentro de un valor concreto que alguien posee, y las garantías de seguridad —no hay dos escrituras que se pisen— se mantienen intactas. Lo único que cambia es quién y cuándo las comprueba: el tipo envoltorio, en vez del borrow checker; en ejecución, en vez de en compilación.

La regla compartir-o-mutar es suficiente para la seguridad, no necesaria

Detente en qué demuestra de verdad el borrow checker. El invariante que Rust necesita para la seguridad de memoria es más estrecho de lo que la regla enuncia: exige que ninguna escritura ocurra a la vez que otro acceso al mismo dato, es decir, que no haya un acceso mutable simultáneo a otro cualquiera. “Compartir XOR mutar” es una aproximación conservadora y estáticamente verificable de ese invariante: una condición suficiente —si la cumples, estás a salvo— pero no necesaria —puedes estar a salvo sin cumplirla al pie de la letra—. El precio de esa conservadurez es que el compilador rechaza programas correctos: sabe que Rc::clone es seguro, pero no puede probarlo con las herramientas del análisis estático, así que, de no existir una escapatoria, lo prohibiría. La mutabilidad interior es precisamente esa escapatoria disciplinada: conserva el invariante verdadero —nunca dos accesos en conflicto— pero lo hace cumplir por un camino distinto al de la regla sintáctica. Cell lo garantiza aboliendo las referencias al interior, de modo que no hay aliasing posible que violar; RefCell lo garantiza contando los préstamos en ejecución y abortando antes de permitir un conflicto; Mutex lo garantiza serializando el acceso con un cerrojo. En los tres casos la seguridad no se debilita ni un ápice: solo se traslada el momento y el mecanismo de la prueba, del compilador al tipo, de la compilación a la ejecución. Comprender esto disuelve la sensación de estar “haciendo trampa”: no engañas al sistema de tipos, operas en la franja legítima que separa lo demostrable estáticamente de lo verdaderamente seguro. Ahí, en esa franja, vive toda la mutabilidad interior, y con ella Rc, los grafos, las cachés y buena parte de la infraestructura sobre la que se apoya el resto del lenguaje.

📝
Lo esencial del problema

La mutabilidad en Rust es heredada: escribes solo por un camino de dueño o &mut, nunca a través de un &T compartido. Pero hay diseños legítimos que necesitan justo eso —el recuento de Rc, cachés, observadores, grafos—, y la regla compartir-o-mutar, que es suficiente pero no necesaria para la seguridad, los prohibiría. La mutabilidad interior es la excepción controlada: tres tipos —Cell, RefCell y UnsafeCell— que permiten mutar tras un & conservando el invariante verdadero por una vía distinta a la del borrow checker.

⚔️ Reconoce dónde la regla se queda corta
  1. Escribe un struct Contador { visitas: u32 } con un método registrar(&self) que intente self.visitas += 1. Lee el error del compilador y explica por qué &self lo impide.
  2. Con Rc::new y Rc::clone, imprime Rc::strong_count antes y después de clonar. Argumenta por qué esa cuenta tiene que mutar a través de un &.
  3. Define con tus palabras la diferencia entre mutabilidad heredada y mutabilidad interior, sin usar la palabra “trampa”.
  4. Enumera tres casos reales (fuera de Rc) que exijan mutar tras una referencia compartida y di qué tienen en común.
  5. Explica por qué “compartir XOR mutar” es una condición suficiente pero no necesaria para la seguridad de memoria, y qué papel juega la mutabilidad interior en esa distinción.