Borrowing y Lifetime en Rust a través de ejemplos

1. Descripción general

Borrowing y lifetime son dos conceptos que hacen que los principiantes en Rust se esfuercen más con el compilador. Este artículo explica borrowing (&T, &mut T) y lifetime annotation ('a) a través de ejemplos de dominio familiar como Order, Customer. Después de leerlo, podrás entender los mensajes de error del borrow checker, saber cuándo necesitas escribir lifetime annotations y cuándo el compilador puede inferirlas automáticamente.

2. Recordatorio rápido sobre Ownership

Antes de hablar de borrowing, recuerda: cada valor en Rust tiene un solo propietario. Cuando pasas un valor por valor, la propiedad se mueve.

struct Customer {
    name: String,
}

fn print_customer(customer: Customer) {
    println!("Cliente: {}", customer.name);
}

fn main() {
    let customer = Customer { name: String::from("Nguyễn Văn A") };
    print_customer(customer);
    // println!("{}", customer.name); // error: customer ha sido movido
}

print_customer recibe Customer por valor, así que customer se mueve dentro de la función. Si descomenta la línea comentada obtendrá un error:

error[E0382]: borrow of moved value: `customer`

Borrowing surge para resolver exactamente este problema: permitir que otras funciones “tomen prestados” datos sin necesidad de asumir la propiedad.

3. Borrowing inmutable: &T

Usa &T cuando solo necesitas leer datos, sin necesidad de modificarlos.

struct Order {
    items: Vec<f64>,
}

fn calculate_total(order: &Order) -> f64 {
    order.items.iter().sum()
}

fn main() {
    let order = Order { items: vec![100.0, 250.0, 50.0] };
    let total = calculate_total(&order);

    println!("Total: {}", total);
    println!("Cantidad de artículos: {}", order.items.len()); // order sigue siendo usable
}
Total: 400
Cantidad de artículos: 3

calculate_total recibe &Order – una referencia inmutable. order sigue siendo propiedad de main, la función solo “toma prestado” temporalmente para leer. Después de llamar la función, main puede seguir usando order sin problemas. Puedes crear múltiples &Order simultáneamente, siempre que no haya ningún &mut Order en el medio.

4. Borrowing mutable: &mut T

Cuando necesitas modificar datos a través de una referencia, usa &mut T.

struct Order {
    items: Vec<f64>,
    discount_percent: f64,
}

fn apply_discount(order: &mut Order, percent: f64) {
    order.discount_percent = percent;
}

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };
    apply_discount(&mut order, 10.0);

    println!("Descuento: {}%", order.discount_percent);
}
Descuento: 10%

Nota dos cosas importantes: la variable order debe declararse con mut, y la llamada a la función debe pasar &mut order. A diferencia de &T, solo puede existir una &mut T en cualquier momento, y no puede haber ningún &T vivo al mismo tiempo.

5. Borrow checker: la regla “un propietario, múltiples lectores o un escritor”

La regla central: para un dato, en cualquier momento solo puedes tener múltiples &T u un solo &mut T, no ambos.

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };

    for item in &order.items {
        order.items.push(*item); // error: order.items está siendo prestado de forma inmutable para el bucle
    }
}

for item in &order.items crea una referencia inmutable que vive durante todo el bucle. Dentro del bucle, order.items.push(...) necesita tomar prestado mutablemente el mismo dato → conflicto. El compilador informa:

error[E0502]: cannot borrow `order.items` as mutable because it is
also borrowed as immutable

La solución común: recoge los valores que necesitas agregar en un Vec temporal, luego haz extend después de que termine el bucle, es decir, separa los dos borrow en momentos diferentes.

Nota: el borrow checker analiza según “el alcance de vida real” (non-lexical lifetimes), no por el bloque {}. Dos borrows que no se superponen en tiempo de vida son válidos, incluso si están en la misma función.

6. ¿Qué es Lifetime?

Lifetime es la forma en que Rust describe cuánto tiempo “vive una referencia”, para asegurar que nunca uses una referencia después de que los datos originales han sido destruidos. La mayoría del tiempo no necesitas escribir lifetime explícitamente – el compilador puede inferirlo. Pero cuando una función acepta múltiples referencias y devuelve una referencia, el compilador necesita que indiques explícitamente de cuál parámetro “toma prestada” la referencia devuelta.

6.1. Lifetime annotation en funciones

fn longest_name<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let customer1 = String::from("Nguyễn Văn A");
    let customer2 = String::from("Trần Thị B más largo");

    let longest = longest_name(&customer1, &customer2);
    println!("Nombre más largo: {}", longest);
}
Nombre más largo: Trần Thị B más largo

'a no crea un tiempo de vida nuevo, es solo un nombre para vincular: tanto a, b como el valor de retorno deben vivir al menos 'a. El compilador no sabe en tiempo de ejecución si la función retorna a o b, así que obliga a que tanto los dos inputs como el output compartan un mismo lifetime para garantizar seguridad en cualquier caso.

Si escribes incorrectamente – devolviendo una referencia no relacionada con el lifetime declarado:

fn longest_name_broken<'a>(a: &'a str, _b: &str) -> &'a str {
    let local = String::from("temporal");
    &local // error: local es destruido al final de la función
}
error[E0515]: cannot return value referencing local variable `local`

6.2. Lifetime en structs

Los structs que contienen referencias también necesitan lifetime annotations, porque Rust debe asegurar que el struct no “viva más” que los datos que prestó.

struct Customer {
    name: String,
}

struct OrderSummary<'a> {
    customer: &'a Customer,
    total: f64,
}

fn build_summary<'a>(customer: &'a Customer, total: f64) -> OrderSummary<'a> {
    OrderSummary { customer, total }
}

fn main() {
    let customer = Customer { name: String::from("Lê Văn C") };
    let summary = build_summary(&customer, 500.0);

    println!("{} - total: {}", summary.customer.name, summary.total);
}

OrderSummary<'a> no es propietario de Customer, solo mantiene una referencia. El lifetime 'a obliga a que summary no pueda vivir más que customer. Si dejas que customer sea destruido antes de usar summary, el compilador lo bloquea en compile-time – ningún dangling pointer puede llegar al runtime.

6.3. Cuándo no necesitas escribir lifetime (elision)

Rust tiene 3 reglas para inferir lifetime automáticamente (lifetime elision), que aplican a la mayoría de funciones:

  1. Cada parámetro que es una referencia obtiene su propio lifetime.
  2. Si la función tiene exactamente un input lifetime, se asigna a la salida.
  3. Si hay &self o &mut self, el lifetime de self se asigna a la salida.
fn first_word(text: &str) -> &str {
    // regla 2: solo 1 input lifetime -> se asigna automáticamente a la salida
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

Esta función no necesita escribir 'a porque tiene solo un parámetro de referencia – el compilador infiere automáticamente que la salida toma prestado de text. Solo necesitas escribir lifetime explícitamente cuando tienes más de un parámetro de referencia y es ambiguo de dónde proviene la salida.

7. Ejemplo práctico: parser que devuelve slice

fn first_word(text: &str) -> &str {
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

fn main() {
    let order_note = String::from("envío rápido dentro del día");
    let word = first_word(&order_note);

    println!("Primera palabra: {}", word);
}
Primera palabra: envío

first_word no crea un String nuevo, solo devuelve un slice (&str) que vive en la memoria de order_note. Este es un patrón muy común en parsers Rust: evitar asignaciones adicionales, solo “apuntar” a los datos originales. El lifetime asegura que word no pueda vivir más que order_note.

Intenta escribir incorrectamente para ver cómo el compilador te protege:

fn first_word_broken() -> &str {
    let text = String::from("envío rápido dentro del día");
    first_word(&text) // text es destruido cuando la función termina
}
error[E0106]: missing lifetime specifier

El compilador se niega a compilar porque no hay manera de asignar un lifetime válido a la salida – text definitivamente morirá antes de que el valor devuelto se use en el lugar de la llamada.

Test para first_word

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn devuelve_primera_palabra_correcta() {
        let note = "envío rápido dentro del día";
        assert_eq!(first_word(note), "envío");
    }

    #[test]
    fn devuelve_frase_completa_sin_espacios() {
        let note = "envío";
        assert_eq!(first_word(note), "envío");
    }
}

Ejecuta cargo test:

running 2 tests
test tests::devuelve_primera_palabra_correcta ... ok
test tests::devuelve_frase_completa_sin_espacios ... ok

8. Errores comunes

  • Devolver una referencia a una variable local: la variable se destruye al final de la función, pero la referencia sigue “viva” en el lugar de la llamada → el compilador la bloquea con un error de lifetime, nunca deja que llegue al runtime.
  • Mantener &T demasiado tiempo antes de pedir prestado &mut T: ocurre cuando guardas el resultado de .iter() en una variable y luego mutas la struct original en el mismo scope. Solución: reduce el alcance del borrow inmutable con un bloque {} o separa la lógica de lectura/escritura en dos pasos.
  • Abusar de clone() para evadir el borrow checker: clone() siempre compila, pero tiene el costo de copiar datos. Solo úsalo cuando realmente necesites una copia independiente, no para “apagar incendios” de errores de borrow.
  • Escribir lifetime annotations innecesarias: si la función solo tiene una referencia de entrada, no escribas 'a manualmente – la regla de elision ya lo maneja, escribirlo solo hace el código más desordenado.

Nota: si encuentras que necesitas agregar 'static para que el código compile, detente y reconsider el diseño. 'static fuerza que los datos vivan durante todo el programa, generalmente es una señal de que estás eludiendo el problema real de ownership en lugar de resolverlo.

9. Conclusión

Borrowing te permite tomar prestados datos sin necesidad de asumir la propiedad, con la regla “múltiples lectores o un escritor” verificada por el borrow checker en compile-time. Lifetime es el lenguaje para que el compilador y tú se pongan de acuerdo sobre cuánto tiempo vive una referencia, especialmente importante cuando una función devuelve una referencia o un struct contiene referencias internas. En la mayoría de los casos, el compilador infiere el lifetime automáticamente a través de la regla de elision; solo necesitas escribirlo manualmente cuando tienes múltiples parámetros de referencia de entrada y es ambiguo de dónde proviene la salida.

rustborrowinglifetimeownershipmemory-safety