Ownership y borrowing en Rust: guía práctica sin el libro oficial

En Rust, cada valor pertenece a una sola variable. Eso es todo. Esa variable es el propietario, y cuando deja de existir (cuando sale del ámbito donde fue declarada), el valor se libera. No hay un recolector de basura mirando desde detrás, ni tienes que acordarte de hacer free(). El compilador sabe exactamente cuándo ocurre y genera el código de limpieza por ti.

Un ejemplo mínimo:

let a = String::from("hola");
let b = a; // b es ahora el dueño
// a ya no existe. Si intentas usarla, el compilador da error.

Esto se llama move: la propiedad se transfirió de a a b. No es una copia. Es un traspaso. Si después intentas usar a, el compilador te para en seco con un mensaje claro.

La ventaja es enorme: en ningún momento hay dos variables que crean estar gestionando el mismo dato en el heap. Sin doble liberación, sin memoria colgante, sin sorpresas en producción.

Move vs. Copy: cuándo se copia y cuándo se traspasa

No todos los tipos se comportan igual. Rust distingue dos categorías:

  • Move: el tipo transfiere la propiedad cuando lo asignas o lo pasas a una función. String, Vec y Box son los ejemplos más habituales. Viven en el heap, y mover su propiedad es barato porque solo se mueve el puntero, no los datos.
  • Copy: el tipo se duplica automáticamente. i32, f64, bool y char son Copy. Son pequeños y viven solo en el stack, así que copiarlos no cuesta nada.

La regla práctica: si el tipo tiene datos en el heap (una cadena de texto, un vector, un valor en un Box), es move. Si todo está en el stack (un número, un booleano, un carácter), es Copy.

¿Y si necesitas una copia de un tipo move? Puedes derivar el trait Clone y llamar a .clone() explícitamente:

#[derive(Clone)]
struct Punto {
    x: f64,
    y: f64,
}

let p1 = Punto { x: 1.0, y: 2.0 };
let p2 = p1.clone(); // copia explícita
// p1 sigue siendo válido

La palabra clave es «explícitamente». Rust no hace copias caras a tus espaldas. Si ocurre una copia costosa, tú la has pedido.

Borrowing: usar un valor sin quedártelo

Mover la propiedad no siempre tiene sentido. A veces solo quieres que una función lea un valor sin llevárselo. Para eso están las referencias:

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

let texto = String::from("programación");
let n = longitud(&texto);
// texto sigue siendo válido aquí
println!("{}", texto);

El &texto crea una referencia: la función longitud puede leer el String, pero no toma la propiedad. Cuando la función termina, la referencia desaparece, y texto sigue perteneciendo a quien lo creó.

A esto se le llama borrowing (préstamo). El propietario presta su valor temporalmente, y lo recupera cuando el préstamo termina.

Puedes tener tantas referencias de lectura como quieras al mismo tiempo. Son inmutables por defecto, así que no hay conflicto posible.

Borrowing mutable: modificar sin transferir la propiedad

Si necesitas modificar el valor prestado, usas una referencia mutable:

fn añadir_exclamacion(s: &mut String) {
    s.push_str("!");
}

let mut texto = String::from("hola");
añadir_exclamacion(&mut texto);
println!("{}", texto); // "hola!"

Aquí la regla es más estricta: solo puede existir una referencia mutable al mismo tiempo. Y mientras existe esa referencia mutable, no puede haber ninguna referencia inmutable activa sobre el mismo valor.

let mut s = String::from("texto");
let r1 = &s;       // referencia inmutable, OK
let r2 = &mut s;   // ERROR: ya hay una referencia inmutable activa

¿Por qué tanta restricción? Porque este modelo elimina los data races por definición. Un data race ocurre cuando dos hilos acceden al mismo dato simultáneamente y al menos uno de ellos escribe. Si el compilador garantiza que solo hay un modificador activo en cada momento, esa categoría entera de bugs desaparece en tiempo de compilación, no en producción.

Cuando lo entiendes así, el borrow checker deja de parecer un obstáculo y empieza a parecer una red de seguridad.

Lifetimes: cuánto tiempo vive una referencia

El compilador rastrea la vida de cada referencia para asegurarse de que nunca apunta a algo que ya no existe. Eso se llama análisis de lifetimes.

La mayoría del tiempo no tienes que escribir nada: el compilador los infiere solo. Pero hay casos donde no puede hacerlo, principalmente cuando una función devuelve una referencia:

fn primera_palabra<'a>(s: &'a str) -> &'a str {
    let bytes = s.as_bytes();
    for (i, &byte) in bytes.iter().enumerate() {
        if byte == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}

La anotación 'a le dice al compilador: «la referencia que devuelvo vive tanto como la referencia que recibo». Así puede verificar que quien llame a esta función no use el resultado después de que el argumento haya desaparecido.

Si una función toma una sola referencia y devuelve una referencia, el compilador asume que el retorno tiene el mismo lifetime que el argumento. Eso cubre la mayoría de los casos sin que tengas que anotar nada.

Donde más se notan los lifetimes es en structs que guardan referencias:

struct Extracto<'a> {
    parte: &'a str,
}

La anotación indica que el struct no puede vivir más que la cadena de texto a la que apunta parte. Lógico: si el dato original desaparece, la referencia quedaría colgando.

Los errores más comunes y cómo leerlos

El compilador de Rust tiene fama de dar mensajes de error claros, y es merecida. Cuando algo falla, el mensaje suele incluir exactamente qué está mal, en qué línea y, la mayoría de las veces, un hint con la solución. Estos son los errores de ownership que vas a ver más:

«cannot move out of `x` because it is borrowed»

Tienes una referencia activa sobre x y en algún punto intentas mover x. La referencia dice «voy a usar esto», y el move dice «esto ya no existe». Rust no lo permite. Solución habitual: deja que la referencia salga de ámbito antes de mover.

«cannot borrow `x` as mutable because it is also borrowed as immutable»

Hay una &x activa y estás intentando crear un &mut x. Reorganiza el código para que las referencias inmutables terminen antes de crear la mutable. Desde Rust 2018 el borrow checker es más preciso y a veces acepta código que antes rechazaba si el compilador puede ver que los ámbitos no se solapan de verdad.

«does not live long enough»

Una referencia apunta a algo que ya se liberó. El ejemplo clásico: creas un valor dentro de un bloque, guardas una referencia a él fuera del bloque, y cuando el bloque termina el valor desaparece pero la referencia sigue ahí. Rust lo detecta antes de compilar.

let referencia;
{
    let valor = String::from("temporal");
    referencia = &valor; // ERROR: valor no vive suficiente
}
// aquí valor ya no existe, pero referencia intenta apuntar a él

La solución suele ser mover el valor fuera del bloque o cambiar el diseño para que la referencia y el valor tengan el mismo ámbito.

Un consejo que funciona: cuando el compilador da un error de ownership, lee siempre el hint que viene debajo del mensaje principal. Suele ser más útil que el propio error.

Rc y Arc: cuando necesitas varios dueños

El modelo de ownership con un único propietario cubre la mayoría de los casos. Pero a veces necesitas que varias partes del código compartan el mismo dato sin una jerarquía clara de propiedad. Para eso existen Rc<T> y Arc<T>.

  • Rc<T> (Reference Counted): mantiene un contador de cuántas referencias existen. Cuando el contador llega a cero, libera la memoria. Solo válido en código de un único hilo.
  • Arc<T> (Atomic Reference Counted): igual que Rc, pero el contador es atómico y puede usarse entre hilos de forma segura.
use std::rc::Rc;

let dato = Rc::new(String::from("compartido"));
let copia1 = Rc::clone(&dato);
let copia2 = Rc::clone(&dato);
// Los tres apuntan al mismo String. Se libera cuando los tres salen de ámbito.

Si además necesitas mutabilidad sobre un valor compartido, puedes combinar Rc con RefCell<T>. RefCell pospone las comprobaciones del borrow checker al tiempo de ejecución en lugar de hacerlas en compilación, lo que añade algo de coste pero da flexibilidad en situaciones donde la estructura del código hace difícil satisfacer al compilador estáticamente.

Dicho esto: si te encuentras usando Rc<RefCell<T>> con frecuencia, vale la pena replantearse el diseño. Suele haber una forma más directa de estructurar el código con ownership simple que es más fácil de entender y de mantener. Estos tipos existen para casos específicos, no como solución por defecto cuando el borrow checker protesta.

Si quieres ver por qué este modelo de memoria es uno de los argumentos más sólidos a favor de Rust, puedes leer más sobre por qué Rust sigue creciendo o cómo se compara el ownership de Rust comparado con Go.

El truco mental que más ayuda

Antes de pelearte con el borrow checker, hazte una pregunta simple: ¿cuánto tiempo necesita vivir este valor y quién es responsable de él?

Si una función solo necesita leer un dato, recibe una referencia inmutable. Si necesita modificarlo, una referencia mutable. Si necesita quedárselo (guardarlo en una struct, devolverlo, pasarlo a un hilo), toma la propiedad.

Cuando el código está bien diseñado, el compilador suele aceptarlo sin pelea. Si da un error de ownership, casi siempre es porque hay una decisión de diseño sin resolver: ¿quién es responsable de este dato? ¿cuánto tiene que durar? Responde esas preguntas y el código se escribe solo.

El borrow checker no es un filtro arbitrario. Es la formalización de algo que los programadores de C y C++ intentan hacer manualmente durante años con mayor o menor éxito. Rust solo te obliga a ser explícito al respecto desde el principio.

Imagen: Pexels / Tanha Tamanna Syed

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP