Translate

viernes, 27 de febrero de 2026

Ownership y préstamos en Rust


Rust introduce dos ideas que, juntas, son su rasgo distintivo: ownership (propiedad) y borrowing (préstamos).

No son trucos sintácticos: son un modelo de memoria que permite seguridad en tiempo de compilación (sin garbage collector) y rendimiento predecible.

Ownership: cada valor en Rust tiene un dueño (variable). Cuando el dueño sale de scope, el valor se libera.

Move: asignar o pasar por valor mueve la propiedad por defecto (para tipos no Copy).

Borrow / préstamo: podés tomar prestado un valor como referencia inmutable &T o mutable &mut T.

Reglas clave: puedes tener muchas referencias inmutables simultáneas, o una referencia mutable — nunca ambas.

Lifetimes: anotaciones que garantizan que las referencias no apunten a memoria liberada.


En Rust cada valor tiene exactamente un dueño. Cuando el dueño deja de existir (sale del scope), Rust llama drop automáticamente.


fn main() {

    let s1 = String::from("hola"); // s1 es dueño del heap con "hola"

    let s2 = s1; // move: s1 ya no es válido

    // println!("{}", s1); // ERROR: s1 fue movido a s2

    println!("{}", s2); // OK

}


Para tipos que implementan el trait Copy (primitivos como i32, bool), la asignación copia el valor en vez de moverlo.


Si querés duplicar un String, podés clonar explícitamente:


let s1 = String::from("hola");

let s2 = s1.clone(); // copia profunda

println!("{}", s1); // OK

println!("{}", s2); // OK


Mentalidad: en Rust la transferencia de propiedad es explícita — evita copias accidentales que degradan rendimiento.


En lugar de mover la propiedad, podés prestar acceso usando referencias:


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

    s.len()

}


fn main() {

    let s = String::from("hola");

    let len = longitud(&s); // prestamos &s, s sigue siendo dueño

    println!("La longitud es {}", len);

}


Para modificar lo prestado, necesitás referencia mutable:


fn agregar_excl(s: &mut String) {

    s.push('!');

}


fn main() {

    let mut s = String::from("hola");

    agregar_excl(&mut s);

    println!("{}", s); // "hola!"

}


Rust impone reglas estrictas (en tiempo de compilación):

  1. Puedes tener cero o más referencias inmutables (&T) simultáneas.
  2. O puedes tener una sola referencia mutable (&mut T) — y ninguna inmutable simultánea.
  3. Las referencias deben ser válidas mientras se usan (lifetime).

Estas reglas evitan condiciones de carrera y referencias colgantes sin costo en tiempo de ejecución.


Ejemplo de error típico: aliasing mutable


fn main() {

    let mut s = String::from("hola");

    let r1 = &s;       // préstamo inmutable

    let r2 = &s;       // otro préstamo inmutable

    // let r3 = &mut s; // ERROR: no se puede pedir &mut mientras existan &


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

}


Si intentás mezclar &T y &mut T en solapamiento, el compilador falla. Para arreglarlo, reorganizá el scope para que las referencias inmutables dejen de usarse antes de pedir la mutable.

Rust no permite que una referencia viva más que el valor apuntado. Los lifetimes (habitualmente inferidos) son la forma del compilador de garantizarlo.


Ejemplo que no compila:


fn devuelve_referencia() -> &String {

    let s = String::from("hola");

    &s // ERROR: s se libera al salir del scope

}


Corrección: la referencia debe apuntar a algo que viva lo suficiente (por ejemplo, una referencia pasada desde afuera), o devolver el valor moviéndolo en vez de devolver una referencia.


Con funciones que reciben y devuelven referencias a datos externos, a veces necesitás anotar lifetimes explícitamente:


fn mayor<'a>(x: &'a str, y: &'a str) -> &'a str {

    if x.len() >= y.len() { x } else { y }

}


Aquí a declara que la referencia retornada vivirá al menos tanto como las referencias x y y. Las anotaciones ayudan al compilador a razonar sobre efectos temporales; muchas veces Rust infiere las anotaciones por vos.


El borrow checker es el mecanismo que verifica las reglas de préstamos y lifetimes en compilación.

Sus efectos:

  • Evita use after free y data races en concurrencia sin coste en tiempo de ejecución.
  • Obliga a estructurar el código para que la propiedad y los préstamos sean claros.
  • A veces produce mensajes que parecen rígidos, pero suelen guiar a diseños más sanos.


Ejemplo concurrente: Arc<T> + Mutex<T> para compartir datos entre hilos, respetando ownership y seguridad en tiempo de compilación.


En APIs públicas se acostumbra a recibir &T o &mut T para no traspasar propiedad innecesariamente:


fn procesar_entrada(input: &str) {

    // no move; quien llamó conserva ownership

}


Si la función necesita poseer el valor (p. ej. para almacenarlo más tarde), puede aceptar el tipo por valor y dejar claro que la función toma ownership.


Si lo comparamos con los demás lenguajes: 

  • Con lenguajes con GC (Java, C#): el programador no se preocupa por liberar memoria; el runtime limpia por detrás. Ventaja: simplicidad. Desventaja: coste de runtime y pausas, menos control sobre rendimiento. Rust ofrece control y cero-cost abstractions, a cambio de más esfuerzo en la fase de diseño.
  • Con C++ (RAII y move semantics): C++ tiene destrucción automática (RAII) y std::move. Rust toma ideas de RAII y las hace seguras por defecto con el borrow checker. Rust evita UB típico de C++ (use-after-free, double-free) con verificaciones en compilación más estrictas.
  • Con lenguajes dinámicos (Python, JS): la ergonomía es máxima en dinámicos, pero sin garantías en tiempo de compilación. Rust sacrifica algo de ergonomía inicial a favor de seguridad y rendimiento claros.

Errores frecuentes y cómo resolverlos:


1. Intentar usar una variable después de un move

    Síntoma: mensaje value moved here, used here after move.

    Soluciones: pasar por referencia &T, clonar (clone()) si necesitás duplicar, o reorganizar para no necesitarla después del move.


2. Confundir &T y &mut T

   Síntoma: error al pedir &mut mientras hay &.

   Solución: reducir el scope de las referencias previas o copiar/clone si es necesario.


3. Lifetimes complicados

   Síntoma: errores diciendo que lifetimes no coinciden.

   Solución: reestructurar para que la propiedad viva lo suficiente (por ejemplo, mover datos al struct que necesita conservarlos) o usar String en vez de &str para asegurar ownership.


4. Intentar retornar referencia a variable local

   Síntoma: borrow checker lo rechaza.

   Solución: devolver el valor (mover) o hacer que el llamador pase el buffer/propiedad.


Veamos un ejemplo: 


use std::sync::{Arc, Mutex};

use std::thread;


fn main() {

    let contador = Arc::new(Mutex::new(0)); // compartido entre hilos


    let mut handles = vec![];

    for _ in 0..5 {

        let contador_clonado = Arc::clone(&contador);

        let h = thread::spawn(move || {

            let mut num = contador_clonado.lock().unwrap();

            *num += 1;

        });

        handles.push(h);

    }


    for h in handles {

        h.join().unwrap();

    }


    println!("Contador = {}", *contador.lock().unwrap());

}


Aquí combinamos Arc (ownership compartida thread-safe) y Mutex (control de acceso mutable). Rust garantiza que las operaciones sean seguras en tiempo de compilación y ejecución (los deadlocks siguen siendo responsabilidad del diseño).


Ownership y borrowing son el corazón de la promesa de Rust: código seguro y rápido sin un garbage collector.

La curva de aprendizaje existe y vale la pena: te obliga a pensar en cómo fluyen los datos, evitando muchas clases de bugs que en otros lenguajes aparecen en producción.

Si estás diseñando APIs públicas en Rust, piensa en quién debe poseer los datos y cuándo conviene pasar ownership, versus prestar por referencia. Esa decisión marca la ergonomía de la API y la facilidad para usarla de forma correcta.