Aprende Rust

Lección 19 de 26 Genéricos, traits y módulos

Lifetimes

  • 4 min de lectura
  • 3 ejercicios
  1. r = &x: r es una referencia que apunta a x. Una referencia nunca puede vivir más que su valor.
  2. Al cerrar la llave, x se destruye y r apuntaría a la nada: una referencia colgante. Rust no lo permite.
  3. El borrow checker compara tiempos de vida: x muere antes de que se use r, así que no compila.
  4. En mas_larga<'a>, 'a no alarga nada: dice que el resultado vive lo que la más corta de x e y.
1/4

Cada referencia en Rust tiene un lifetime (tiempo de vida): el tramo del programa durante el cual es válida. Casi siempre el compilador lo deduce solo y ni te enteras. Pero hay casos en los que no puede adivinarlo y te pide que lo anotes tú.

Los lifetimes tienen fama de difíciles, pero la idea de fondo es sencilla: garantizar que ninguna referencia apunta a un valor que ya no existe.

Referencias colgantes

Una referencia colgante (dangling reference) apunta a memoria que ya se ha liberado. En C es un bug clásico y peligroso. En Rust, directamente no compila:

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    }
    println!("r: {r}");
}

x se destruye al cerrar la llave interior, pero r sigue apuntando a ella y se usa después. El compilador lo detecta con el error `x` does not live long enough.

El borrow checker compara ámbitos

La parte del compilador que hace esta comprobación es el borrow checker. Para cada referencia, compara cuánto vive ella con cuánto vive el valor al que apunta:

r vive durante el lifetime 'a y x solo durante 'b, que es más corto. Como r apunta a algo que muere antes que ella, el programa se rechaza. Si el valor vive al menos tanto como la referencia, todo bien:

fn main() {
    let x = 5;
    let r = &x;
    println!("r: {r}");
}

Lifetimes en funciones

Vamos a escribir una función que devuelve el más largo de dos textos:

fn mas_larga(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    println!("{}", mas_larga("Rust", "Ferris"));
}

No compila: missing lifetime specifier. La función devuelve una referencia, pero ¿prestada de x o de y? Depende de un if que se decide en ejecución, así que el compilador no puede saber durante cuánto tiempo será válido el resultado.

La solución es añadir un parámetro de lifetime. Se declara entre < >, como los genéricos, y su nombre empieza por un apóstrofo. Por convención se usa 'a:

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

fn main() {
    let texto1 = String::from("Rust");
    let texto2 = String::from("Ferris");
    println!("La más larga es {}", mas_larga(&texto1, &texto2));
}

Qué significa realmente ‘a

Una anotación de lifetime no alarga la vida de nada. Solo describe una relación entre referencias para que el compilador pueda comprobarla. La firma anterior dice: existe un tramo 'a durante el cual x e y son válidas, y el resultado también es válido durante ese tramo.

En la práctica, 'a acaba siendo el más corto de los dos. Por eso esto no compila, aunque a simple vista el resultado sea cadena1, que vive hasta el final:

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

fn main() {
    let cadena1 = String::from("un texto muy largo");
    let resultado;
    {
        let cadena2 = String::from("corto");
        resultado = mas_larga(&cadena1, &cadena2);
    }
    println!("La más larga es {resultado}");
}

El compilador no ejecuta el if: solo mira la firma. Según ella, resultado podría apuntar a cadena2, que se destruye al cerrar la llave.

Si el resultado depende de un único parámetro, anota solo ese. Aquí y no necesita 'a porque nunca se devuelve:

fn primera<'a>(x: &'a str, y: &str) -> &'a str {
    println!("Ignoro {y}");
    x
}

Reglas de elisión

Si cada función con referencias necesitara anotaciones, Rust sería agotador. Por eso el compilador aplica tres reglas de elisión y, si con ellas queda claro, no te pide nada:

  1. Cada parámetro que es una referencia recibe su propio lifetime.
  2. Si hay un único lifetime de entrada, se asigna a todas las referencias de salida.
  3. Si uno de los parámetros es &self o &mut self, su lifetime se asigna a las salidas.
EscribesEl compilador entiende
fn primera_palabra(s: &str) -> &strfn primera_palabra<'a>(s: &'a str) -> &'a str (regla 2)
fn nombre(&self) -> &strEl resultado vive lo mismo que self (regla 3)
fn mas_larga(x: &str, y: &str) -> &strNinguna regla decide: hay que anotar

Por eso una función como primera_palabra compila sin anotaciones: tiene una sola referencia de entrada. mas_larga tiene dos y ninguna es &self, así que las reglas no bastan.

Structs que guardan referencias

Hasta ahora tus structs eran dueños de sus datos (String, i32…). También pueden guardar referencias, pero entonces tienes que anotar el lifetime:

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

impl<'a> Extracto<'a> {
    fn longitud(&self) -> usize {
        self.parte.len()
    }
}

fn main() {
    let novela = String::from("Llamadme Ismael. Hace unos años...");
    let primera_frase = novela.split('.').next().unwrap_or("");
    let extracto = Extracto { parte: primera_frase };
    println!("«{}» ocupa {} bytes", extracto.parte, extracto.longitud());
}

Extracto<'a> se lee: un Extracto no puede vivir más que la referencia que guarda en parte. Si novela se destruyera antes que extracto, el compilador lo rechazaría. Fíjate en que el impl también declara <'a>, igual que hacías con los genéricos.

Consejo: Si dudas, haz que tus structs sean dueños de sus datos (String en lugar de &str). Es más sencillo y la copia rara vez importa. Guarda referencias en un struct cuando de verdad quieras evitar copiar.

El lifetime ‘static

'static es un lifetime especial: la referencia es válida durante todo el programa. Todos los literales de texto lo tienen, porque se guardan dentro del propio binario:

fn main() {
    let saludo: &'static str = "Hola, Ferris";
    println!("{saludo}");
}

Cuidado: Cuando hay problemas de lifetimes, a veces el compilador sugiere 'static. Casi nunca es la solución: suele significar que intentas devolver una referencia a algo que se destruye. En ese caso, devuelve un valor con dueño, como un String.

Hora de practicar

Ejercicios

0/3 resueltos

Reto 1 de 3

mas_corta no compila porque devuelve una referencia y el compilador no sabe de qué parámetro sale. Añade un parámetro de lifetime que relacione los dos textos con el resultado. Si miden lo mismo, devuelve x.
Salida esperada
Rust

Al ejecutar, unos tests ocultos comprueban tus funciones.

src/main.rs
$ cargo run Listo

Pulsa Ejecutar para compilar y ver aquí la salida de tu programa.

¿Algo no cuadra en este reto? Avísanos