NLL: un préstamo muere en su último uso, no al cerrar la llave
Los préstamos no léxicos (Non-Lexical Lifetimes) enseñaron al borrow checker a medir la vida real de una referencia sobre el grafo de flujo, no su envoltorio sintáctico. Qué modelo reemplazaron, qué patrones cotidianos desbloquearon sin cambiar ninguna regla, los préstamos en dos fases, y dónde llegan sus límites.
Durante años, el borrow checker creía que un préstamo duraba hasta la llave de cierre del bloque que lo contenía. Programas triviales y perfectamente seguros eran rechazados por esa lectura sintáctica del tiempo, y había que meter llaves artificiales para “acortar” préstamos a mano. Los préstamos no léxicos —NLL, Non-Lexical Lifetimes— cambiaron eso enseñando al compilador a medir la vida real de cada referencia sobre el grafo de flujo de control: un préstamo vive desde que nace hasta su último uso, y ni una instrucción más.
- Entender qué modelo léxico reemplazó NLL y por qué era demasiado conservador.
- Ver que el préstamo se libera en su último uso, calculado sobre el flujo de control.
- Reconocer los patrones cotidianos que NLL desbloqueó, incluidos los préstamos en dos fases.
- Distinguir el alcance léxico de una variable de la vida real de su préstamo.
Antes de NLL: el préstamo duraba hasta la llave
En el borrow checker original, la vida de un préstamo coincidía con el scope léxico de la referencia: desde su declaración hasta la llave que cerraba su bloque. Era simple de implementar y catastróficamente pesimista. Este código, que no tiene nada de inseguro, no compilaba:
fn main() {
let mut v = vec![1, 2, 3];
let primero = &v[0]; // préstamo compartido
println!("{primero}"); // último uso REAL de `primero`
v.push(4); // bajo el modelo léxico: ERROR, `primero` sigue "viva"
println!("{v:?}");
}
El problema no era el código, sino la teoría del compilador sobre el tiempo. primero seguía en scope léxicamente hasta el final de main, así que el checker asumía que su préstamo también, y el push chocaba contra un préstamo que en la práctica ya había terminado. La única salida era mentirle al compilador con la sintaxis: envolver el préstamo en un bloque { ... } para forzar su muerte antes del push. Ruido puro, sin ningún valor semántico.
“Léxico” significa determinado por la estructura del texto —las llaves, los bloques—. “No léxico” significa determinado por el comportamiento —qué se ejecuta y en qué orden—. NLL trasladó el cálculo de la vida de un préstamo del primer criterio al segundo: dejó de mirar dónde está escrita la referencia y empezó a mirar hasta dónde se usa.
NLL: la vida real sobre el grafo de flujo
Desde NLL —estabilizado para todas las ediciones y hoy el comportamiento por defecto— el préstamo vive desde su creación hasta su último uso, y el compilador lo calcula analizando el MIR, la representación intermedia de tu función como un grafo de flujo de control. Sobre ese grafo determina, para cada punto del programa, qué préstamos siguen vivos (podrían usarse más adelante) y cuáles ya están muertos. El ejemplo anterior compila sin tocar una coma:
fn main() {
let mut v = vec![1, 2, 3];
let primero = &v[0]; // el préstamo nace
println!("{primero}"); // ...y muere AQUÍ, en su último uso
v.push(4); // OK: cuando se pide el &mut, ya no hay préstamo vivo
println!("{v:?}"); // [1, 2, 3, 4]
}
La variable primero sigue en scope hasta el final de la función, pero su préstamo terminó en el println!. Esa es la distinción que hay que grabar a fuego: el scope de una variable y la vida de su préstamo son dos cosas distintas. La primera la fija la sintaxis; la segunda, el uso. NLL es lo que hace que el borrow checker se sienta razonable en vez de pedante, porque mide lo que de verdad ocurre.
El mismo mecanismo entiende el flujo condicional. Un préstamo que solo se usa dentro de una rama está muerto en cuanto esa rama termina:
fn describir(datos: &mut Vec<i32>) {
if let Some(&primero) = datos.first() { // préstamo compartido, efímero
println!("empieza por {primero}"); // último uso del préstamo
}
datos.push(0); // OK: el préstamo del `first` ya murió al salir del `if`
}
Ese mismo principio hace que un préstamo dentro de un bucle muera al final de cada iteración, no al terminar el bucle, lo que permite alternar lectura y mutación una vuelta tras otra:
fn main() {
let mut v = vec![1, 2, 3];
for _ in 0..3 {
let ultimo = &v[v.len() - 1]; // préstamo compartido en esta iteración
let copia = *ultimo; // último uso: el préstamo muere aquí
v.push(copia); // OK: cada vuelta empieza sin préstamo vivo
}
println!("{v:?}"); // [1, 2, 3, 3, 3, 3]
}
Qué desbloqueó: patrones que antes exigían malabares
NLL no cambió ninguna regla de seguridad —sigue rigiendo alias XOR mutación—; solo dejó de rechazar programas seguros por un cálculo del tiempo demasiado grueso. El contraste es tangible si comparas el rodeo que exigía el modelo léxico con la naturalidad de hoy:
// Antes de NLL había que escribir esto para "cerrar" el préstamo a mano:
fn main() {
let mut v = vec![1, 2, 3];
{
let primero = &v[0];
println!("{primero}");
} // llave artificial cuyo ÚNICO fin era matar el préstamo antes de tiempo
v.push(4); // hoy el bloque sobra: el préstamo ya muere en su último uso
}
El efecto práctico fue enorme. Tres patrones que antes obligaban a introducir bloques artificiales o variables intermedias hoy simplemente funcionan.
Leer y luego mutar
Tomar una referencia, usarla, y mutar el mismo valor más abajo. Antes exigía un bloque para cerrar el préstamo; con NLL basta con que el último uso preceda a la mutación.
Préstamos en ramas
Prestar dentro de un if o un match y mutar después. NLL ve que el préstamo no escapa de la rama y lo da por muerto al salir de ella.
Reasignar tras usar
Reescribir una variable prestada una vez que su préstamo dejó de usarse, sin envolver nada en llaves solo para complacer al checker.
Junto a NLL llegó una pieza complementaria, los préstamos en dos fases (two-phase borrows), que resuelven un choque muy concreto y frecuentísimo: llamar a un método mutable pasándole un argumento que lee el mismo valor.
fn main() {
let mut v = vec![1, 2, 3];
v.push(v.len() as i32); // `push` quiere &mut v; `v.len()` necesita &v ANTES
println!("{v:?}"); // [1, 2, 3, 3]
}
Sin dos fases, esto no compilaría: push reclama &mut v para toda la llamada, pero v.len() —evaluado como argumento— necesita un &v compartido que colisiona con él. Los préstamos en dos fases separan el préstamo mutable en dos momentos: primero se reserva (comportándose como compartido mientras se evalúan los argumentos) y solo se activa como exclusivo cuando la llamada de verdad empieza. Así v.len() completa su lectura antes de que el &mut se active, y el patrón funciona sin que tengas que extraer la longitud a una variable temporal.
Un préstamo en dos fases sigue respetando alias XOR mutación: en ningún instante hay un compartido y un mutable activos a la vez. La fase de reserva no es un préstamo exclusivo todavía; se convierte en exclusivo justo después de que el préstamo compartido del argumento ha terminado. Es puro reordenamiento temporal del mismo invariante, exactamente en el espíritu de NLL: mirar cuándo se usa de verdad cada acceso, no cuándo aparece escrito.
flowchart LR n[Nace el prestamo] --> u1[Uso] u1 --> u2[Ultimo uso del prestamo] u2 --> muerto[El prestamo muere aqui bajo NLL] muerto --> mut[Ya se puede pedir un prestamo mutable] mut --> fin[La variable sigue en scope pero sin prestamo vivo]
Los límites de NLL: hasta dónde no llega
NLL fue un salto, pero no el final del camino. Su análisis sigue siendo conservador en un caso célebre: cuando un préstamo se devuelve condicionalmente desde una función, NLL lo considera vivo en todos los caminos, incluso en aquellos donde en realidad no se usa. Este patrón, sano y frecuente, todavía lo rechaza:
fn ultimo_o_insertar(v: &mut Vec<i32>) -> &i32 {
if let Some(x) = v.last() { // préstamo compartido de `v`
return x; // solo se usa en ESTE camino
}
v.push(0); // ERROR bajo NLL: cree que el préstamo de `last` sigue vivo aquí
v.last().unwrap()
}
En el camino del else, el préstamo de v.last() está muerto —no devolvimos nada—, así que el push sería seguro. Pero NLL, por cómo modela las vidas, no sabe distinguir caminos con esa finura y bloquea la función entera. La cura hoy es reestructurar (tema de la próxima lección), pero la cura de fondo es un análisis más preciso. Ese análisis tiene nombre —Polonius— y es el protagonista de la siguiente lección.
El paso de préstamos léxicos a no léxicos parece un detalle técnico de implementación, pero es uno de los cambios de filosofía más profundos en la historia del lenguaje. El borrow checker original razonaba sobre el texto de tu programa: dónde estaban las llaves, qué bloque contenía qué declaración. NLL lo hizo razonar sobre el comportamiento: qué instrucciones se ejecutan, en qué orden, y hasta qué punto del flujo cada dato sigue importando. Es la diferencia entre un corrector que cuenta paréntesis y uno que entiende lo que dices. Y el resultado tiene una propiedad casi filosófica: acercó el conjunto de programas aceptados al conjunto de programas seguros. Todo verificador estático vive con esa tensión —rechazar algún programa correcto es el precio de no aceptar jamás uno peligroso—, y la calidad de un borrow checker se mide por cuánto reduce esa brecha de “seguros pero rechazados” sin ceder ni un milímetro en seguridad. NLL la estrechó drásticamente, y al hacerlo transformó la experiencia diaria: el compilador dejó de frenarte por accidentes sintácticos y empezó a frenarte solo cuando de verdad rondaba un problema. Cada vez que hoy lees una referencia y mutas el dato dos líneas después sin pensarlo, o llamas a v.push(v.len()) sin un temporal, estás disfrutando de que alguien enseñó al compilador a seguir el hilo de tu programa en lugar de contar sus llaves.
NLL calcula la vida de un préstamo sobre el grafo de flujo, no sobre las llaves: el préstamo vive desde su creación hasta su último uso y muere ahí, aunque la variable siga en scope. Eso desbloqueó los patrones cotidianos de leer-luego-mutar y prestar-en-una-rama sin envolver nada en bloques artificiales, y los préstamos en dos fases dejaron pasar v.push(v.len()). NLL no relajó ninguna regla de seguridad; solo dejó de rechazar programas seguros. Su límite conocido —el retorno condicional de un préstamo— es exactamente lo que Polonius viene a resolver.
- Escribe el patrón leer-luego-mutar sobre un
Vecy confirma que compila; luego mueve elprintln!a después delpushy observa cómo reaparece el E0502. - Explica en una frase la diferencia entre el scope léxico de una variable y la vida de su préstamo.
- Escribe
v.push(v.len() as i32)y razona qué hacen las dos fases del préstamo mutable para que compile. - Reproduce
ultimo_o_insertary confirma que hoy no compila; anota qué camino hace innecesario el conflicto que el compilador cree ver. - Toma código propio con llaves
{ }metidas solo para acortar un préstamo y comprueba si, con NLL, ya sobran.