Un programa en C que libera un buffer y sigue leyéndolo compila sin problema. Luego corrompe la memoria, a veces de inmediato, a veces tres peticiones más tarde, en una parte del código que nunca tocó ese buffer. Rust rechaza ese programa ya en el cargo build, y no hay ningún garbage collector de por medio. El compilador rastrea quién posee cada valor y quién puede acceder a él. Ese rastreo es ownership y borrowing.

¿Cuáles son las reglas de ownership de Rust?

El modelo de ownership de Rust se reduce a tres reglas:

  1. Cada valor tiene exactamente un owner.
  2. Solo puede haber un owner a la vez.
  3. Cuando el owner sale de su scope, el valor se dropea.

“Dropear” significa que su destructor se ejecuta y su memoria se libera de inmediato, no cuando a un garbage collector le toque ocuparse de ello. Ese es el trato de Rust: ningún runtime decide cuándo recuperar memoria, a cambio de que el compilador aplique estas reglas en cada línea que escribes.

La regla 2 es la que confunde a la mayoría al principio. Asigna un String a una segunda variable y la primera deja de funcionar:

1
2
3
4
5
6
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}
1
2
3
4
5
6
7
8
9
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move

let s2 = s1 no copia los bytes del string. String guarda un puntero a un buffer en el heap, una longitud y una capacidad. Copiar ese struct bit a bit dejaría dos owners apuntando al mismo buffer, y cuando ambos salieran de su scope, el buffer se liberaría dos veces. Rust evita eso convirtiendo la asignación en un move: s2 toma posesión del valor, y s1 deja de ser válido. Pasar un String a una función hace lo mismo: la función toma posesión, y la variable que pasaste deja de ser válida en el código que hizo la llamada.

¿Por qué los enteros no se mueven en Rust?

Los enteros no tienen este problema:

1
2
3
4
5
6
fn main() {
    let x = 5;
    let y = x;

    println!("{x} and {y}");
}

Esto compila. i32 implementa el trait Copy, así que la asignación duplica el valor en lugar de moverlo. No hay nada en el heap que se libere dos veces, solo cuatro bytes en el stack. Cada tipo entero, bool, cada tipo de punto flotante, char, y las tuplas formadas solo por tipos Copy ((i32, bool), no (i32, String)) se comportan igual. Un tipo solo puede implementar Copy si ninguna de sus partes implementa Drop: es la manera que tiene Rust de decir que, si la limpieza importa, duplicar tiene que ser explícito.

Cuando quieres una segunda copia independiente de un String o un Vec, lo dices con .clone(). Es una asignación de heap extra, y por eso Rust no la hace automáticamente en cada asignación.

¿Cómo funciona el borrowing en Rust?

Si cada función que necesitara leer un valor tuviera que tomar posesión de él, acabarías devolviendo valores de cada función dentro de una tupla solo para seguir usándolos. El borrowing elimina eso: toma una referencia con &, usa el valor, y la posesión nunca cambia de manos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

s1 sigue siendo válido después de la llamada, porque calculate_length solo lo tomó en préstamo. El tipo del parámetro &String es una referencia: apunta a los datos de s1 sin poseerlos, así que no hay nada que dropear cuando el préstamo termina.

Dos reglas mantienen esto seguro:

  • En un momento dado, puedes tener una sola referencia mutable a un valor, o cualquier número de referencias inmutables, nunca ambas.
  • Una referencia nunca puede vivir más que el dato al que apunta.

Rompe la primera y el compilador te detiene:

1
2
3
4
5
6
7
8
9
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    let r3 = &mut s;

    println!("{r1}, {r2}, and {r3}");
}
1
2
3
4
5
6
7
8
9
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s;
  |              -- immutable borrow occurs here
6 |     let r3 = &mut s;
  |              ^^^^^^ mutable borrow occurs here
8 |     println!("{r1}, {r2}, and {r3}");
  |                -- immutable borrow later used here

Dos referencias mutables al mismo valor a la vez también se rechazan (error[E0499]: cannot borrow `s` as mutable more than once at a time). Ninguno de los dos errores es arbitrario. Una referencia inmutable es una promesa al resto del programa de que el valor no va a cambiar sin que te enteres, y una referencia mutable necesita exclusividad para mantener esa promesa. El bug clásico de thread-safety en C o Go, donde dos partes del código escriben en la misma memoria mientras una tercera la lee, es exactamente lo que estas reglas hacen imposible de compilar.

Eso no significa que Rust atrape cada race condition que podrías escribir. El Rust seguro todavía puede bloquearse en un mutex o equivocarse en el orden de las operaciones, y los bloques unsafe pueden saltarse las comprobaciones por completo. Lo que el borrow checker elimina es una forma específica y muy común del bug: la memoria mutable con alias, detectada antes de que el programa se ejecute.

¿Qué significan las anotaciones de lifetime como ‘a?

Cada referencia tiene un lifetime: el scope en el que es válida. Normalmente el compilador lo resuelve solo. Necesita tu ayuda cuando una función devuelve una referencia y hay más de una referencia de entrada en juego, porque no tiene forma de saber a cuál de las entradas está atada la salida.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

'a es un parámetro de lifetime. Se lee “tick-a”. Es un nombre, no una duración. Escribir &'a str en ambos parámetros y en el tipo de retorno le dice al compilador: la referencia que devuelvo vive, como máximo, tanto como la más corta entre x e y. La anotación no cambia cuánto vive nada. Describe una relación que el compilador ya tiene que verificar, así puede rechazar una llamada donde la referencia devuelta viviría más que una de sus entradas.

No escribes 'a en todas partes, porque Rust lo elide en los casos comunes. Una función con exactamente un parámetro de referencia le asigna ese lifetime a su tipo de retorno automáticamente, y un método que toma &self le asigna el lifetime de self al tipo de retorno. Por eso fn first_word(s: &str) -> &str compila sin ninguna anotación: una entrada, una salida, nada ambiguo. longest toma dos parámetros de referencia independientes y ningún self, así que la elisión no tiene de dónde agarrarse, y el lifetime lo escribes a mano.

Qué hacer cuando el borrow checker choca con tu diseño

Las quejas del borrow checker se sienten hostiles las primeras semanas. Y a veces la versión directa de una estructura de datos no encaja para nada con el modelo de un único owner a la vez: un árbol con punteros al padre, o una caché que dos subsistemas necesitan modificar. Las salidas pragmáticas:

  • .clone() el valor en lugar de pelear con un préstamo. Cuesta una asignación, y es una corrección de una línea que puedes optimizar después, una vez que el resto del código funcione.
  • Rc<T> (o Arc<T> entre threads) para posesión compartida, cuando más de una parte del programa de verdad necesita mantener vivo un valor.
  • RefCell<T> (o Mutex<T> entre threads) cuando necesitas modificar algo a través de una referencia compartida. Esto mueve la comprobación del préstamo del tiempo de compilación al de ejecución, cambiando un error del compilador por un panic si te equivocas.

Ninguna de estas es una solución de la que debas avergonzarte. También insinúan por qué este modelo no es adecuado para todo proyecto. Un script de usar y tirar que lee un archivo e imprime un informe no gana nada rastreando la posesión byte a byte, y un lenguaje con garbage collector y goroutines te lleva más rápido a un programa concurrente que funcione, si la seguridad de memoria en tiempo de compilación no es el requisito. Rust compensa cuando un use-after-free o una data race en producción es el fallo que no te puedes permitir, y prefieres pagarlo por adelantado con errores del compilador.

La próxima vez que rustc rechace tu código con E0382 o E0502, lee el error antes de recurrir a .clone() por reflejo. El error nombra las dos referencias que chocan y dónde empieza cada una, y arreglar el problema de scope que señala normalmente cuesta menos código que tapar el síntoma. Esos mensajes son la forma más rápida de aprender el modelo; son más precisos que la mayoría de las explicaciones sobre él, esta incluida.

Artículos relacionados