Referencias colgantes: el dato nunca muere antes que su referencia
El compilador garantiza que una referencia jamás sobreviva al dato que apunta. Qué es una referencia colgante, por qué en C es un use-after-free y en Rust un error de compilación, y cómo leer 'does not live long enough'.
Una referencia es una promesa: “aquí hay un valor vivo del tipo correcto”. Romper esa promesa —dejar que la referencia siga existiendo cuando el dato que apunta ya se liberó— produce una referencia colgante, y con ella el use-after-free, uno de los bugs más peligrosos y explotados de la historia del software. Rust lo hace, sencillamente, imposible: el borrow checker se niega a compilar cualquier programa en el que una referencia pueda sobrevivir a su dato.
- Definir qué es una referencia colgante y por qué corrompe memoria.
- Leer el error E0597 “borrowed value does not live long enough”.
- Entender por qué no puedes devolver una referencia a un local.
- Enunciar la garantía: la referencia nunca vive más que su referente.
Qué es una referencia colgante
Una referencia colgante (dangling reference) es un puntero a memoria que ya no contiene el valor que se prometía: se liberó, se movió o salió de scope. En C es trivial de escribir y catastrófico de ejecutar:
// C: esto compila, y es un use-after-free
int* colgante() {
int local = 42;
return &local; // devuelve la dirección de una variable que muere aquí
}
Al volver de colgante, local desaparece del stack, pero el puntero devuelto sigue apuntando a esa dirección. Leerlo o escribirlo es comportamiento indefinido: quizá funcione, quizá devuelva basura, quizá sea la grieta por la que entra un atacante. El compilador de C no dice nada. El de Rust rechaza el programa equivalente antes de emitir un solo byte.
C · use-after-free silencioso
return &local; compila sin una queja. En ejecución, seguir el puntero es comportamiento indefinido: basura, crash o vulnerabilidad explotable.
Rust · error de compilación
El programa equivalente no compila. El dangling no es un bug que evitar con disciplina: es un estado que el sistema de tipos no puede representar.
El compilador lo prohíbe: “does not live long enough”
El caso canónico: prestar algo que va a morir antes que la referencia. El borrow checker lo detecta comparando la vida del dato con la vida del préstamo.
fn main() {
let referencia;
{
let efimero = String::from("vida breve");
referencia = &efimero; // se presta algo que va a morir enseguida
} // aquí se libera `efimero`: su buffer desaparece
println!("{referencia}"); // ERROR E0597: `efimero` does not live long enough
}
El mensaje E0597 es exactamente “efimero does not live long enough”, y el compilador anota tres cosas: dónde termina la vida de efimero (la llave de cierre del bloque), dónde nace el préstamo, y dónde se usa la referencia después de que el dato muriera. El conflicto es temporal: la referencia pretende vivir más que el valor al que apunta, y eso el checker no lo permite jamás.
“Does not live long enough” no significa que el valor sea incorrecto, sino que su vida es demasiado corta para el uso que le das. La solución nunca es forzar la referencia, sino alargar la vida del dato (sacarlo del bloque interno) o acortar la de la referencia (usarla antes de que el dato muera). Cuando leas este error, no busques un valor mal escrito: busca dos vidas que no encajan, una referencia que quiere durar más que el dato del que cuelga.
No puedes devolver una referencia a un local
El equivalente exacto del bug de C: una función que crea un valor y devuelve una referencia a él. En Rust ni siquiera se acerca a compilar.
fn colgante() -> &String { // ERROR E0106: missing lifetime specifier
let local = String::from("local");
&local // apuntaría a memoria liberada al volver
}
Aquí el compilador te frena incluso antes, en E0106, porque la firma -> &String no dice de qué dato debería salir esa referencia: no hay ningún parámetro prestado del que provenga. Y si intentaras satisfacer la firma con una vida forzada, chocarías con E0515, “cannot return reference to local variable local”. Ambos errores señalan la misma imposibilidad: la referencia sobreviviría a local, que muere al terminar la función.
La solución no es pelear con el préstamo, sino devolver el dueño. Que la propiedad viaje al llamador es gratis (elisión de la copia) y elimina el problema de raíz: no hay referencia que pueda colgar porque no hay referencia.
fn no_colgante() -> String {
let local = String::from("local");
local // se mueve la PROPIEDAD al llamador; nada queda apuntando a nada muerto
}
flowchart LR crea[Nace el dato] --> presta[Se crea una referencia al dato] presta --> usa[Se usa la referencia] usa --> muere[Muere el dato y se libera] muere --> regla[La referencia debe haber terminado antes de este punto]
Devolver una referencia sí vale si sale de una entrada
Devolver referencias es perfectamente legítimo cuando la referencia sale de un dato que el llamador ya posee, típicamente un parámetro prestado. Ahí la referencia no cuelga: apunta a algo que vive fuera de la función y le sobrevive.
fn primero<'a>(v: &'a [i32]) -> &'a i32 {
&v[0] // la referencia devuelta vive tanto como el préstamo de entrada `v`
}
fn main() {
let numeros = vec![10, 20, 30];
let r = primero(&numeros); // `numeros` sigue vivo mientras usemos `r`
println!("{r}"); // 10
}
Esa anotación 'a es un lifetime: le dice al compilador que la referencia de salida vive exactamente lo mismo que la de entrada, atando ambas vidas. Los lifetimes son el tema del siguiente nivel; por ahora basta con leer la firma como “te devuelvo una referencia que dura lo que dure la que me diste”. Como el dato pertenece al llamador, la referencia devuelta nunca puede colgar.
No dejes que la sintaxis 'a te intimide: no añade ninguna regla nueva, solo pone nombre a la relación de vidas que el borrow checker ya estaba verificando. En la inmensa mayoría de las funciones, la elisión de lifetimes los infiere sola y no escribes ninguno. Los anotas a mano solo cuando una firma es ambigua sobre de qué entrada sale una referencia de salida. La garantía subyacente —la referencia no sobrevive a su dato— es la misma con anotación explícita o sin ella.
Los slices son préstamos que no pueden colgar
Un slice —&str, &[T]— es una referencia a una porción contigua de otro dato: no lo posee, solo lo mira. Como toda referencia, no puede sobrevivir al dato del que sale, y el borrow checker lo impone con la misma regla que ya conoces. El caso canónico es tomar un slice de un Vec y luego intentar hacerlo crecer:
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let ventana = &v[1..3]; // slice: préstamo compartido de una porción
// v.push(6); // ERROR E0502: `push` necesita `&mut v`, pero `ventana` vive
println!("{ventana:?}"); // [2, 3]
}
Si el push se permitiera, el Vec podría realojar su buffer para crecer y ventana quedaría apuntando a memoria liberada: un slice colgante y un use-after-free en toda regla. Es exactamente la invalidación de iteradores de C++, y el borrow checker la corta con la regla de siempre: mientras el slice compartido viva, nadie muta el Vec. El slice hereda su seguridad temporal del dato que recorta, sin coste ni anotación.
Devolver un slice desde una función es igual de seguro e idiomático: la referencia de salida sale de la entrada prestada, así que nunca puede colgar. Y como la relación de vidas es la evidente, la elisión de lifetimes la infiere sola y no escribes ninguna anotación:
fn primera_mitad(v: &[i32]) -> &[i32] {
&v[..v.len() / 2] // slice que vive exactamente lo que viva la entrada `v`
}
fn main() {
let numeros = vec![1, 2, 3, 4];
let mitad = primera_mitad(&numeros); // `numeros` vive mientras usemos `mitad`
assert_eq!(mitad, &[1, 2]);
println!("{mitad:?}"); // [1, 2]
}
La seguridad de memoria tiene dos caras, y conviene no confundirlas. La espacial es no salirte de los límites de un objeto: no leer el elemento 11 de un array de 10. La temporal es no acceder a un objeto fuera de su ventana de vida: no tocar memoria antes de que exista o después de liberarla. Muchos lenguajes atacan la espacial con comprobación de límites en ejecución, pero la temporal —el use-after-free, el dangling, el double-free— es históricamente la más difícil y la que alimenta la mayoría de las vulnerabilidades críticas de C y C++. La contribución radical de Rust es demostrar la seguridad temporal en compilación y con coste cero, mediante una idea de una simetría preciosa: si el ownership garantiza que un dato se libera exactamente cuando muere su dueño, y el borrow checker garantiza que ninguna referencia sobrevive a ese dato, entonces el instante de liberación nunca puede caer dentro de la vida de una referencia. La ventana de validez de cada referencia está contenida, por construcción, en la ventana de vida de su referente. No hay verificación en ejecución, no hay recolector rastreando punteros vivos: hay una prueba estática de que el tiempo, esa dimensión que a C se le escapa, está bajo control. Por eso una referencia en Rust seguro es una promesa que el compilador ha demostrado que no se romperá.
Una referencia jamás puede sobrevivir al dato que apunta: el borrow checker compara ambas vidas y rechaza todo programa donde la referencia dure más. El error se lee “does not live long enough” (E0597) o “cannot return reference to local variable” (E0515). La cura no es forzar el préstamo, sino devolver la propiedad: mueve el dueño hacia el llamador y no quedará ninguna referencia que pueda colgar.
- Reproduce el ejemplo del bloque interno y lee el E0597 completo; localiza la llave donde muere el dato y la línea donde se usa la referencia.
- Escribe una función que intente devolver
&localy clasifica si obtienes E0106 o E0515; luego arréglala devolviendo elStringpor valor. - Escribe
fn mas_largo<'a>(a: &'a str, b: &'a str) -> &'a strque devuelva el más largo, y razona por qué necesita un lifetime. - Explica, con la simetría ownership/borrow, por qué el punto de liberación nunca cae dentro de la vida de una referencia.
- Distingue en una frase seguridad de memoria espacial y temporal, y di cuál resuelve el borrow checker.