wandres.dev
BORROWING · referencias y reglas

Las reglas del borrow checker: alias O mutación

En cada instante, un valor admite muchas referencias compartidas O una sola mutable, nunca ambas. Cómo esta única regla —aliasing XOR mutabilidad— elimina las data races por diseño, y qué son los préstamos no léxicos.

⏱ 17 min

Has visto las dos mitades por separado: muchas referencias compartidas de solo lectura, o una única mutable exclusiva. El borrow checker es el componente del compilador que las une en una sola ley y la impone sin excepción: en cualquier punto del programa, o coexisten cuantos lectores quieras, o manda un único escritor, pero jamás las dos cosas a la vez. Esta regla —aliasing XOR mutabilidad— es, literalmente, lo que hace imposible una data race en Rust seguro.

🎯 Al terminar esta lección sabrás
  • Enunciar la regla del préstamo: muchas &T o una &mut T, nunca ambas.
  • Leer y diagnosticar el error E0502 cuando compartido y mutable chocan.
  • Entender los préstamos no léxicos: el borrow acaba en su último uso.
  • Explicar por qué la regla elimina las data races por construcción.

La regla: alias O mutación, nunca ambos

Toda la disciplina de préstamos cabe en dos invariantes que el checker nunca deja de exigir. Primero, toda referencia debe apuntar siempre a un valor vivo (el tema del dangling, siguiente lección). Segundo, y es el que nos ocupa: en cada punto, para cada valor, se cumple exactamente una de estas dos situaciones.

👀

Alias: muchos lectores

Cualquier número de referencias compartidas &T a la vez. Todos leen, nadie escribe: sin lectores traicionados.

✍️

Mutación: un escritor

Una única &mut T, y ninguna otra referencia. Un solo escritor con acceso exclusivo: sin competencia.

// Situación A: muchos lectores compartidos, permitido.
let v = vec![1, 2, 3];
let a = &v;
let b = &v;
let c = &v;
println!("{} {} {}", a.len(), b.len(), c.len()); // tres lectores conviven

// Situación B: un único escritor exclusivo, permitido.
let mut w = vec![1, 2, 3];
let m = &mut w;
m.push(4);   // el único acceso a `w` mientras `m` viva

Lo que nunca compila es mezclarlas: un lector compartido y un escritor mutable vivos a la vez sobre el mismo dato.

El choque en acción: E0502

fn main() {
    let mut saldo = 100;
    let lector = &saldo;         // préstamo compartido: empieza aquí
    let escritor = &mut saldo;   // ERROR E0502: ya hay un `&` compartido vivo
    println!("{lector} {escritor}");
}

El mensaje es preciso: “cannot borrow saldo as mutable because it is also borrowed as immutable”. El compilador señala tres puntos —dónde nace el préstamo compartido, dónde intentas el mutable, y dónde se usa el compartido después— porque el conflicto solo existe si los dos préstamos se solapan en el tiempo. Esa observación es la puerta a la regla que de verdad aplica el checker moderno. El arreglo casi nunca es clonar: basta con ordenar los accesos en el tiempo para que no se solapen —usar el compartido y terminar con él antes de pedir el mutable—, como veremos enseguida.

Préstamos no léxicos: el borrow acaba en su último uso

Un malentendido común es creer que un préstamo dura hasta la llave de cierre del bloque. No es así desde el borrow checker no léxico (NLL). Un préstamo vive desde que se crea hasta su último uso, no hasta el final del scope. El checker analiza el grafo de flujo de control y libera el préstamo en cuanto la referencia deja de usarse.

fn main() {
    let mut v = vec![1, 2, 3];

    let lector = &v;
    println!("{}", lector.len()); // ÚLTIMO uso de `lector`: el préstamo acaba AQUÍ

    v.push(4);   // OK: ya no hay ningún préstamo compartido vivo
    println!("{v:?}");
}

Aunque lector sigue “en scope” léxicamente hasta el final de main, su préstamo terminó en la línea del println!. Por eso el push posterior compila: cuando se pide el préstamo mutable, el compartido ya no está vivo. NLL es lo que hace que el borrow checker se sienta razonable en lugar de pedante; muchos programas que antes eran rechazados hoy compilan porque el checker mide la vida real de cada referencia, no su envoltorio sintáctico.

El mismo mecanismo hace desaparecer un préstamo temporal en cuanto se consume, aunque conviva en la misma línea con la mutación posterior:

fn main() {
    let mut cuenta = vec![10, 20];
    let suma: i32 = cuenta.iter().sum(); // préstamo compartido efímero: nace y muere aquí
    cuenta.push(suma);                   // OK: el préstamo de `iter` ya terminó
    println!("{cuenta:?}");              // [10, 20, 30]
}
ℹ️
Por qué NLL cambió la experiencia de escribir Rust

Antes de los préstamos no léxicos, el préstamo se extendía hasta el final del bloque, y patrones triviales —leer una referencia y luego mutar el mismo valor más abajo— exigían meter llaves artificiales para “acortar” la vida del préstamo. NLL eliminó ese ruido: el compilador calcula la región mínima en la que cada referencia está viva sobre el grafo de flujo. El efecto práctico es que el borrow checker rechaza casi exactamente los programas peligrosos, y muy pocos programas seguros de más. Cuando el checker te frena, la probabilidad de que haya un problema real es alta.

flowchart TB
v[Un valor en un instante] --> q[El checker exige una de dos]
q -->|opcion A| a[Muchas referencias compartidas de solo lectura]
q -->|opcion B| b[Una sola referencia mutable de lectura y escritura]
a --> x[Nunca las dos a la vez]
b --> x

Por qué esto elimina las data races por diseño

Una data race tiene una definición técnica precisa: dos o más accesos a la misma posición de memoria, desde hilos distintos, donde al menos uno escribe y ninguno está sincronizado. Descompón esa definición y verás que sus ingredientes son exactamente aliasing (varios accesos al mismo sitio) más mutación (al menos una escritura). La regla del borrow checker prohíbe esa combinación en cualquier contexto, incluido un solo hilo.

La clave es que Rust extiende la misma disciplina de referencias a los hilos. Compartir datos entre hilos pasa por los mismos & y &mut, gobernados por dos traits que verás más adelante (Send y Sync). El resultado es que, para que dos hilos toquen el mismo dato, o ambos lo hacen a través de & (solo lectura, sin carrera posible) o el acceso mutable está protegido por un tipo de sincronización como Mutex. Un &mut desnudo compartido entre hilos sencillamente no compila.

use std::thread;

fn main() {
    let datos = vec![1, 2, 3];

    thread::scope(|s| {
        s.spawn(|| println!("hilo A lee: {}", datos.len())); // &datos
        s.spawn(|| println!("hilo B lee: {}", datos.len())); // &datos
    }); // ambos hilos solo comparten LECTURA: sin data race, y compila
}

Si cualquiera de esos closures intentara mutar datos, el checker exigiría exclusividad y el programa no compilaría: no puedes tener un escritor y un lector simultáneos, ni entre líneas ni entre hilos. La ausencia de data races no se consigue con un detector en ejecución ni con revisiones de código: se consigue negándose a compilar el único patrón que las causa.

La vía de escape: mutabilidad interior

¿Y si de verdad necesitas mutar un dato que está compartido? La regla de exclusividad parece prohibirlo tajantemente, pero Rust ofrece una salida disciplinada: la mutabilidad interior. Tipos como Cell<T> y RefCell<T> permiten mutar a través de una referencia compartida &T, trasladando la comprobación de aliasing más mutación del tiempo de compilación al tiempo de ejecución.

use std::cell::RefCell;

fn main() {
    let bitacora = RefCell::new(Vec::new());

    let lector = &bitacora;               // referencia COMPARTIDA
    lector.borrow_mut().push("evento A"); // ...y aun así mutamos, vía préstamo dinámico
    lector.borrow_mut().push("evento B");

    println!("{:?}", bitacora.borrow());  // ["evento A", "evento B"]
}

RefCell no rompe la regla: la reimpone en ejecución. borrow y borrow_mut llevan la cuenta de los préstamos vivos y, si detectan un mutable simultáneo con cualquier otro, entran en pánico en lugar de compilar algo inseguro. Es el mismo invariante —alias XOR mutación—, solo que verificado con un contador dinámico en vez de con una prueba estática.

⚠️
La mutabilidad interior mueve la comprobación, no la elimina

RefCell es una herramienta, no un atajo para acallar al borrow checker. Si abusas de ella cambias errores de compilación —gratis y seguros— por panics en ejecución —caros y tardíos—. Resérvala para los casos donde de verdad necesitas mutación compartida (grafos, cachés, el patrón observador) y donde puedes razonar que los préstamos dinámicos nunca se solaparán. La regla no desaparece: solo cambia quién la vigila y cuándo salta la alarma.

Aliasing XOR mutabilidad: un axioma del que cuelga toda la seguridad

Casi todo Rust se deduce de una sola disyunción exclusiva: o compartes, o mutas, nunca ambas al mismo tiempo. Parece una regla local sobre referencias, pero es en realidad el axioma central de seguridad del lenguaje, y su alcance es asombroso. De él sale la ausencia de data races, porque una carrera es aliasing más mutación concurrente. De él sale la imposibilidad de la invalidación de iteradores, porque mutar un contenedor mientras lo recorres sería aliasing más mutación. De él salen optimizaciones que C solo consigue con la anotación frágil restrict, porque el compilador sabe que a través de un &mut nadie más escribe y a través de un & nadie escribe en absoluto. Y de él sale, sorprendentemente, la mayor parte de la corrección lógica: muchísimos bugs que no son de memoria —un callback que reentra y corrompe un estado a medio actualizar, un caché que alguien modifica mientras otro lo consulta— son, en el fondo, aliasing más mutación disfrazados. Rust no verifica tu programa contra una lista de bugs conocidos; establece un invariante tan fundamental que familias enteras de bugs se vuelven inexpresables. Cuando el borrow checker rechaza tu código, no está siendo quisquilloso: te está señalando que has escrito, aunque no lo veas, la forma general de una data race.

📝
Lo esencial de la regla del préstamo

En cada instante y para cada valor: o muchas &T compartidas de solo lectura, o una única &mut T exclusiva, nunca ambas. Gracias a los préstamos no léxicos (NLL), cada préstamo vive solo hasta su último uso, no hasta el final del bloque. De esa disyunción exclusiva —alias XOR mutación— sale la ausencia de data races por construcción; y cuando necesitas mutar algo compartido, RefCell reimpone la misma regla en ejecución.

⚔️ Piensa como el borrow checker
  1. Crea tres & compartidos al mismo Vec y úsalos a la vez; confirma que compila.
  2. Añade un &mut mientras uno de esos compartidos sigue vivo y lee el E0502 completo, identificando los tres puntos que señala.
  3. Reordena el código para que el último uso del compartido quede antes del préstamo mutable, y observa que gracias a NLL ahora compila.
  4. Define “data race” con sus cuatro ingredientes y marca cuál de ellos rompe la regla de exclusividad.
  5. Escribe un thread::scope con dos hilos que solo leen un Vec; luego haz que uno intente mutar y explica por qué deja de compilar.