wandres.dev
LIFETIMES · validez de las referencias

Por qué existen los lifetimes: nombrar la región donde una referencia vale

Un lifetime es la región del código en la que una referencia es válida. Por qué el compilador necesita nombrar esas regiones, la garantía que imponen —ninguna referencia sobrevive a su dato— y por qué se borran antes de generar código: coste cero en ejecución.

⏱ 16 min

Ya conoces la ley: ninguna referencia puede sobrevivir al dato que apunta. Un lifetime es la herramienta con la que el compilador razona sobre esa ley. No es un mecanismo nuevo ni una segunda capa de reglas: es el nombre que el borrow checker le pone a la región del código donde una referencia es válida, para poder compararla con la región donde su dato está vivo. Los lifetimes no alargan ni acortan la vida de nada; solo hacen explícita —y verificable— una relación que siempre estuvo ahí.

🎯 Al terminar esta lección sabrás
  • Definir un lifetime como la región del código en la que una referencia es válida.
  • Enunciar la garantía: la región de la referencia cabe dentro de la de su dato.
  • Ver que toda referencia lleva un lifetime, casi siempre inferido y anónimo.
  • Comprender que los lifetimes se borran en compilación: cero coste en ejecución.

Un lifetime es una región, no una duración

El nombre engaña. “Tiempo de vida” sugiere una duración medida en nanosegundos, pero técnicamente un lifetime es una región: un conjunto de puntos del programa —del grafo de flujo de control— en los que una referencia está viva y puede desreferenciarse con seguridad. No se mide en tiempo de reloj, se mide en puntos de código. Por eso los préstamos no léxicos (NLL) tienen sentido: la región de una referencia acaba en su último uso, no en la llave de cierre.

fn main() {
    let dato = String::from("hola");   // nace el dato
    let r = &dato;                     // empieza la REGIÓN de `r`
    println!("{r}");                   // último uso: aquí TERMINA la región de `r`
    // desde aquí `r` ya no está viva, aunque `dato` siga en scope
    println!("{dato}");                // `dato` se sigue usando por valor
} // aquí muere `dato` y se libera su buffer

La región de r es el tramo que va de su creación a su último uso: una rodaja del programa delimitada por puntos, no por segundos. Entender el lifetime como región —y no como duración— es lo que hace que todo lo demás encaje.

Conviene separar dos palabras que la intuición confunde. El scope es léxico: el bloque de llaves donde un nombre es visible. El lifetime es de flujo: la región donde una referencia de verdad se usa. Suelen parecerse, pero divergen en cuanto una referencia deja de usarse antes de que cierre su bloque, y esa divergencia es precisamente la que NLL explota para aceptar programas correctos que el análisis léxico antiguo rechazaba de más.

fn main() {
    let mut v = vec![1, 2, 3];
    let primero = &v[0];        // empieza la región de `primero`
    println!("{primero}");      // último uso: la región de `primero` TERMINA aquí
    v.push(4);                  // OK: ya no hay ninguna referencia compartida viva
    println!("{v:?}");          // [1, 2, 3, 4]
}

Aunque primero sigue “en scope” hasta el final de main, su región se cerró en el println!, y por eso el push posterior compila: cuando se pide el préstamo mutable, la región del compartido ya terminó. Región y scope divergen, y el checker mide siempre la primera.

Toda referencia lleva un lifetime, casi siempre invisible

Cada tipo referencia es, en realidad, &'a T: un puntero más un lifetime. Cuando escribes &i32, el compilador lee &'_ i32, inventa una variable de región anónima y la resuelve por ti. No los escribes casi nunca gracias a la elisión (lección 4), pero están siempre presentes en el razonamiento del checker.

let x = 10;
let r: &i32 = &x;        // lo que escribes
// let r: &'a i32 = &x;  // lo que el compilador razona: una región 'a concreta
📦

Región del dato

El tramo del programa en que el valor referido está vivo e inicializado: desde que nace hasta que su dueño lo libera. Es la región grande.

🔎

Región de la referencia

El tramo en que la referencia puede usarse: de su creación a su último uso. Debe caber entera dentro de la región del dato.

Una misma firma puede manejar varias regiones a la vez, una por cada referencia independiente. El compilador no las funde: las trata como variables distintas y solo las relaciona cuando tú se lo pides. Por eso conviene pensar en los lifetimes en plural —un conjunto de regiones que el checker resuelve de golpe— y no como una única “vida” global del programa.

// Tres referencias, tres regiones potencialmente distintas:
fn suma(a: &i32, b: &i32, c: &i32) -> i32 { a + b + c }
// el compilador razona: fn suma<'a, 'b, 'c>(a: &'a i32, b: &'b i32, c: &'c i32)
// ninguna región se ata a otra porque la salida `i32` no es una referencia

El lifetime tampoco es exclusivo de &T: también &mut T es en realidad &'a mut T, y las regiones asoman en structs, enums, objetos de trait y closures. Cada vez que un tipo contiene una referencia, arrastra consigo la región de esa referencia.

let mut n = 0;
let m: &mut i32 = &mut n;   // en realidad &'a mut i32: el préstamo mutable también tiene región
*m += 1;                    // la región de `m` termina en su último uso, como toda referencia

La garantía: contención de regiones

El corazón del borrow checker es una única comprobación. Para cada referencia con lifetime 'r que apunta a un dato con lifetime 'd, exige que 'd: 'r —que se lee “'d sobrevive a 'r”—: la región del dato contiene a la región de la referencia. Dicho con conjuntos, la región de la referencia es un subconjunto de la región de validez del dato.

fn main() {
    let r;                          // declaramos la referencia...
    {
        let efimero = 42;           // región de `efimero`: solo este bloque
        r = &efimero;               // 'r pretendería salir del bloque...
    } // aquí muere `efimero`: su región TERMINA
    // println!("{r}");             // ERROR E0597: 'r no cabe dentro de la de `efimero`
}
flowchart LR
d[Region del dato: nace y muere con su dueno] --> contiene[Debe contener por completo]
contiene --> r[Region de la referencia: de su creacion a su ultimo uso]
r --> ok[Si cabe dentro: compila]
d --> fail[Si la referencia se sale: E0597]

Este es exactamente el error “does not live long enough” del nivel 9, ahora dicho en el idioma de las regiones: la región de la referencia se sale de la de su dato, así que el instante de liberación caería dentro de la ventana en que la referencia aún está viva. La contención de regiones es la formulación precisa de “ninguna referencia sobrevive a su referente”.

Esa contención tiene notación propia: 'd: 'r, “'d sobrevive a 'r”. Y como toda relación de orden, habilita una forma de subtipado de regiones: donde se espera una referencia de región corta, puedes pasar una de región más larga, nunca al revés. Una &'static str encaja allí donde se pide un &'a str cualquiera, porque 'static contiene a toda región; lo contrario —colar una referencia efímera donde se exige una duradera— es justo lo que el checker corta.

fn usa_corta<'a>(_r: &'a str) {}

fn main() {
    let global: &'static str = "vivo todo el programa";
    usa_corta(global);   // OK: 'static sobrevive a cualquier 'a; se acorta sin peligro
    // dar una región más larga donde se pide una más corta siempre es seguro
}

Coste cero: los lifetimes se borran

Aquí está la magia que distingue a Rust de un lenguaje con recolector. Los lifetimes existen solo en compilación. Una vez que el borrow checker demuestra que todas las regiones encajan, los borra por completo —lifetime erasure— antes de generar código máquina. Un &'a T se compila al mismo puntero desnudo que un T* de C: sin etiqueta de región, sin contador, sin comprobación en ejecución.

// Estas dos firmas generan CÓDIGO MÁQUINA IDÉNTICO:
fn con_lifetime<'a>(x: &'a i32) -> &'a i32 { x }
fn sin_lifetime(x: &i32) -> &i32 { x }
// el 'a solo existe para el checker; en el binario no queda ni rastro

Donde un lenguaje con GC gasta ciclos en ejecución rastreando qué punteros siguen vivos, Rust ya lo dedujo al compilar y tiró el andamiaje. El análisis de regiones es una prueba estática: se paga una vez, en el cargo build, y nunca más.

💡
Lee &'a T de derecha a izquierda: dato, región, préstamo

Cuando te topes con &'a str, descomponla en sus tres partes: str es el tipo del dato, 'a es la región en la que la referencia a ese dato es válida, y & es el préstamo que las une. Entrenarte a leerlas por separado —qué se presta, durante qué región, con qué acceso— convierte los lifetimes de ruido sintáctico en información precisa. Casi todo el miedo a los lifetimes nace de leerlos como un bloque opaco en vez de como lo que son: una etiqueta de región pegada a un préstamo.

ℹ️
Región y último uso: por qué NLL cambió las reglas

Antes de los préstamos no léxicos, la región de una referencia se estiraba hasta el final del bloque léxico, y programas seguros eran rechazados por pedantería. NLL redefinió el lifetime como lo que siempre debió ser: el conjunto mínimo de puntos del grafo de flujo donde la referencia puede usarse. Por eso hoy puedes leer una referencia y, tres líneas después, mutar el mismo dato: la región de la primera ya terminó. Cuando pienses en un lifetime, no pienses “hasta la llave”; piensa “hasta el último uso”.

Un lifetime es una variable de una demostración sobre regiones

Detén la vista aquí, porque esta es la idea que desbloquea todo el nivel. Un lifetime no es documentación ni una anotación decorativa: es una variable de región en un sistema de inferencia, y el borrow checker es un resolutor de restricciones que trabaja sobre ellas. Cada préstamo genera una restricción de la forma “esta región debe estar contenida en aquella”; cada uso de una referencia añade “esta región debe llegar hasta aquí”; cada liberación impone “esta región debe haber terminado antes de este punto”. El compilador junta todas esas desigualdades sobre el grafo de flujo y busca una asignación de regiones que las satisfaga todas a la vez. Si existe, tu programa es temporalmente seguro y las regiones se borran; si no existe, te lo señala con E0597, E0515 o E0106. Lo que parece una sintaxis rara —'a, 'static, 'd: 'r— es en realidad el vocabulario de un teorema: para toda referencia, su región de uso es un subconjunto de la región de validez de su referente. C nunca escribió ese teorema, y por eso paga sus use-after-free en producción a las tres de la madrugada. Rust lo exige antes de emitir un solo byte, y luego lo olvida porque ya está demostrado. Cuando interiorices que anotar un lifetime es declarar una variable en una prueba, dejarás de pelearte con la sintaxis y empezarás a leerla como lo que es: álgebra de regiones con coste cero.

📝
Lo esencial de por qué existen los lifetimes

Un lifetime es la región del código donde una referencia es válida, no una duración temporal: va de la creación de la referencia a su último uso. Toda referencia lleva uno, casi siempre inferido y anónimo (&'a T bajo el &T que escribes). La garantía es la contención de regiones —'d: 'r—: la región de la referencia cabe entera dentro de la de su dato, así que el punto de liberación nunca cae dentro de la vida de la referencia. Y como todo se demuestra en compilación, los lifetimes se borran: en el binario no queda ni rastro.

⚔️ Piensa en regiones
  1. Reescribe el ejemplo de r = &efimero y lee el E0597; identifica dónde termina la región del dato y dónde pretendía llegar la de la referencia.
  2. Añade un println!("{r}") justo antes de la llave que mata a efimero y observa que ahora compila: explica por qué en términos de contención.
  3. Escribe fn identidad<'a>(x: &'a i32) -> &'a i32 y su versión sin anotar; razona por qué generan el mismo código máquina.
  4. Define “lifetime” en una sola frase sin usar la palabra “tiempo”.
  5. Explica por qué que los lifetimes se borren en compilación es lo que hace a Rust competir con C en rendimiento.