AL.
🇺🇸 EN
Volver al blog
Herramientas · 8 min de lectura

Borrowing en Rust: Como Funciona Internamente y Por Que Importa

Entiende el sistema de borrowing de Rust, ownership y el borrow checker con ejemplos de referencias inmutables vs mutables y seguridad de memoria.


Rust es conocido por su seguridad de memoria sin garbage collection. El secreto detrás de esta magia es el sistema de ownership y borrowing. Si vienes de lenguajes con garbage collectors (JavaScript, Python, Java) o gestión manual de memoria (C, C++), el enfoque de Rust puede sentirse extraño al principio. Pero una vez que lo entiendes, se vuelve increíblemente poderoso.

En este artículo, exploraremos cómo funciona el borrowing en Rust, por qué existe, y cómo el borrow checker previene errores de memoria en tiempo de compilación.

El Problema: Seguridad de Memoria Sin Garbage Collection

En lenguajes con garbage collection, no te preocupas por la gestión de memoria. El runtime rastrea qué está en uso y libera memoria automáticamente. El tradeoff es rendimiento impredecible y sobrecarga de runtime.

En C/C++, gestionas memoria manualmente con malloc/free o new/delete. Esto es rápido pero propenso a errores:

  • Use-after-free: Acceder memoria después de liberarla.
  • Double-free: Liberar la misma memoria dos veces.
  • Memory leaks: Olvidar liberar memoria.
  • Data races: Múltiples threads accediendo memoria simultáneamente sin sincronización.

Rust resuelve estos problemas con ownership, borrowing y el borrow checker — todo verificado en tiempo de compilación, sin sobrecarga de runtime.

Ownership: La Fundación

En Rust, cada valor tiene un único owner. Cuando el owner sale de scope, el valor se libera automáticamente.

fn main() {
    let s = String::from("hello"); // s es el owner
    println!("{}", s);
} // s sale de scope, memoria liberada automáticamente

Si intentas usar s después de que salga de scope, obtendrás un error de compilación:

fn main() {
    let s = String::from("hello");
    println!("{}", s);
}
println!("{}", s); // Error: s está fuera de scope

Transferencia de Ownership (Move)

Cuando asignas un valor a otra variable, el ownership se mueve:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // Ownership se mueve de s1 a s2

    println!("{}", s2); // OK
    // println!("{}", s1); // Error: s1 ya no es válido
}

Esto previene que dos variables intenten liberar la misma memoria.

Borrowing: Referencias Sin Transferir Ownership

A veces quieres acceder a un valor sin tomar ownership. Ahí es donde entra borrowing. Creas una referencia al valor, permitiendo acceso temporal sin mover ownership.

Referencias Inmutables (&T)

Puedes crear múltiples referencias inmutables a un valor:

fn main() {
    let s = String::from("hello");
    let r1 = &s; // Borrow inmutable
    let r2 = &s; // Otro borrow inmutable

    println!("{}, {}", r1, r2); // OK: múltiples lecturas están bien
    println!("{}", s); // El owner original todavía es válido
}

Las referencias inmutables te permiten leer datos sin modificarlos. Puedes tener tantas como quieras simultáneamente.

Referencias Mutables (&mut T)

Si necesitas modificar un valor, usas una referencia mutable:

fn main() {
    let mut s = String::from("hello");
    let r = &mut s; // Borrow mutable

    r.push_str(", world");
    println!("{}", r); // "hello, world"
}

Pero aquí está la clave: solo puedes tener UNA referencia mutable a un valor en un scope dado, y no puedes tener referencias inmutables al mismo tiempo.

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s; // Error: no se pueden tener dos refs mutables

    println!("{}, {}", r1, r2);
}
fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // Borrow inmutable
    let r2 = &mut s; // Error: no se puede tomar mutable mientras existe inmutable

    println!("{}, {}", r1, r2);
}

Las Reglas del Borrow Checker

El borrow checker de Rust enforza estas reglas en tiempo de compilación:

  1. En cualquier momento dado, puedes tener:

    • Cualquier número de referencias inmutables (&T), O
    • Exactamente UNA referencia mutable (&mut T).
  2. Las referencias deben siempre ser válidas. No puedes tener una referencia a memoria liberada (sin dangling pointers).

Estas reglas previenen:

  • Data races: Dos threads no pueden mutar el mismo dato simultáneamente.
  • Mutación durante iteración: No puedes modificar una colección mientras iteras sobre ella.
  • Use-after-free: Las referencias no pueden sobrevivir a sus dueños.

Ejemplos: Borrowing en Acción

Ejemplo 1: Pasar Referencias a Funciones

En lugar de mover ownership, pasas referencias:

fn calculate_length(s: &String) -> usize {
    s.len() // Lee el valor sin tomarlo
}

fn main() {
    let s = String::from("hello");
    let len = calculate_length(&s); // Prestamos s

    println!("Length of '{}' is {}", s, len); // s todavía es válido
}

Sin borrowing, tendrías que mover s a la función y luego devolverlo:

fn calculate_length(s: String) -> (String, usize) {
    let len = s.len();
    (s, len) // Devolver ownership
}

fn main() {
    let s = String::from("hello");
    let (s, len) = calculate_length(s);

    println!("Length of '{}' is {}", s, len);
}

Borrowing es mucho más limpio.

Ejemplo 2: Modificar Mediante Referencia Mutable

fn append_world(s: &mut String) {
    s.push_str(", world");
}

fn main() {
    let mut s = String::from("hello");
    append_world(&mut s); // Prestamos mutably

    println!("{}", s); // "hello, world"
}

La función modifica s in-place sin tomar ownership.

Ejemplo 3: Prevenir Data Races

Imagina intentar esto:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // Borrow inmutable
    let r2 = &mut s; // Error: no se puede tomar mutable mientras existe inmutable

    println!("{}, {}", r1, r2);
}

El borrow checker rechaza esto. Si r2 pudiera mutar s mientras r1 lo lee, r1 podría ver datos inconsistentes o inválidos. Rust previene esto en tiempo de compilación.

Lifetimes: Asegurando Que las Referencias Sean Válidas

Las referencias en Rust tienen lifetimes — el scope durante el cual son válidas. El compilador infiere lifetimes la mayor parte del tiempo, pero a veces necesitas anotarlos explícitamente.

Dangling Reference (Prevenido por Rust)

fn dangle() -> &String {
    let s = String::from("hello");
    &s // Error: s sale de scope, retornar una referencia a él es inválido
}

El borrow checker detecta esto y rechaza el código. En C/C++, esto compilaría pero causaría undefined behavior en runtime.

La solución es retornar el valor directamente (mover ownership):

fn no_dangle() -> String {
    let s = String::from("hello");
    s // Ownership se mueve a la función llamante
}

Anotaciones de Lifetime Explícitas

A veces el compilador necesita ayuda para entender cómo se relacionan los lifetimes:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

La anotación 'a dice: “la referencia retornada vivirá tanto como el menor de los lifetimes de x o y.”

fn main() {
    let s1 = String::from("long string");
    let result;
    {
        let s2 = String::from("short");
        result = longest(&s1, &s2); // result tiene el lifetime de s2
    } // s2 sale de scope
    // println!("{}", result); // Error: result no puede sobrevivir a s2
}

Rust fuerza que result no pueda usarse después de que s2 salga de scope, previniendo un dangling pointer.

Patrones Comunes de Borrowing

1. Split Borrowing

Puedes tomar múltiples borrows mutables de diferentes partes de una estructura:

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

    let (left, right) = v.split_at_mut(2);
    left[0] = 10;
    right[0] = 20;

    println!("{:?}", v); // [10, 2, 20, 4]
}

Rust sabe que left y right apuntan a diferentes partes de v, así que permite ambos borrows mutables.

2. Interior Mutability

A veces necesitas mutar datos detrás de una referencia inmutable. RefCell<T> y Cell<T> proporcionan interior mutability con verificaciones de borrowing en runtime:

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(5);

    {
        let mut borrow = data.borrow_mut();
        *borrow += 1;
    } // Borrow mutable sale de scope

    println!("{}", data.borrow()); // 6
}

RefCell enforza las reglas de borrowing en runtime en lugar de tiempo de compilación. Si las violas, el programa entra en pánico en lugar de causar undefined behavior.

3. Copy Types

Tipos simples como i32, f64, bool implementan el trait Copy. Cuando los asignas, se copian en lugar de moverse:

fn main() {
    let x = 5;
    let y = x; // x se copia, no se mueve

    println!("{}, {}", x, y); // Ambos son válidos
}

No necesitas borrowing para tipos Copy porque copiarlos es barato.

Por Qué Borrowing Importa

El sistema de borrowing de Rust elimina clases enteras de bugs:

  • No use-after-free: Las referencias no pueden sobrevivir a sus dueños.
  • No double-free: Solo un owner puede liberar memoria.
  • No data races: Las referencias mutables son exclusivas.
  • No dangling pointers: El borrow checker garantiza validez.

Todo esto se verifica en tiempo de compilación, sin sobrecarga de runtime. Obtienes rendimiento tipo C/C++ con seguridad tipo Python/Java.

Conclusión

El sistema de borrowing de Rust puede sentirse restrictivo al principio, pero existe por una razón: seguridad de memoria sin garbage collection. Al entender ownership, borrowing y el borrow checker, desbloqueas la capacidad de escribir código rápido, seguro y concurrente que simplemente no es posible en otros lenguajes.

Puntos clave para recordar:

  • Ownership: Cada valor tiene un único owner.
  • Borrowing: Puedes prestar referencias (&T o &mut T) sin transferir ownership.
  • Borrow checker: Enforza que solo puedas tener múltiples lecturas O una escritura — nunca ambas.
  • Lifetimes: Aseguran que las referencias siempre apunten a memoria válida.

Dominar borrowing es esencial para escribir Rust idiomático. Una vez que lo entiendes, se siente natural y te preguntas cómo alguna vez escribiste código sin él.