wandres.dev
LIFETIMES AVANZADOS · los casos difíciles

Casos dificiles: estructuras autorreferenciales, temporales y como resolverlos

Los limites donde el sistema de lifetimes se queda sin sintaxis: un struct que apunta a su propio campo, o una funcion que quiere devolver una referencia a un temporal. Por que son imposibles en Rust seguro —mover invalida la referencia, no puedes nombrar tu propia region— y el catalogo de patrones que los sortean: indices y arenas, `Rc`, `Pin`, y crates como ouroboros y self_cell.

⏱ 20 min

Hay una frontera donde toda la maquinaria de lifetimes que has dominado deja de tener sintaxis con que expresarse. Es la de las estructuras autorreferenciales: un struct que guarda un dato y, a la vez, una referencia hacia el interior de ese mismo dato. Parece inofensivo, y sin embargo es imposible en Rust seguro por dos razones que se refuerzan: mover el struct invalidaria la referencia interna, y no existe forma de nombrar la region de un campo desde el propio tipo que lo contiene. Su primo cercano —devolver una referencia a un temporal— es el mismo problema disfrazado. Esta leccion final del nivel explica por que estos casos son duros de verdad, no un capricho del checker, y despliega el catalogo de patrones y crates con que la comunidad los resuelve: indices, arenas, Rc, Pin y macros como ouroboros.

🎯 Al terminar esta lección sabrás
  • Explicar por que un struct autorreferencial es imposible en Rust seguro: la invalidacion al mover y la imposibilidad de nombrar la region propia.
  • Reconocer el mismo problema en “devolver una referencia a un temporal” (E0515) y su cura por posesion.
  • Elegir entre los patrones sin referencias: indices en un Vec, indices generacionales, y arenas con lifetime comun.
  • Situar Pin, Rc/RefCell y los crates especializados (ouroboros, self_cell, yoke) segun el problema que resuelve cada uno.

Por que la autorreferencia es imposible en Rust seguro

Considera el struct que todo el mundo intenta escribir alguna vez: uno que posee un String y guarda una vista &str hacia dentro de el.

struct AutoRef {
    datos: String,
    vista: &str,   // quiere apuntar a datos, pero... con que lifetime?
}

No compila, y el porque es doble. Primero, no puedes nombrar la region: el campo vista necesita un lifetime, pero la unica region valida seria “tan larga como el propio AutoRef”, y un tipo no puede referirse a su propia vida desde dentro. Cualquier 'a que inventes sera, o bien externo (y entonces vista no apunta a datos, sino a algo de fuera), o bien inexpresable. Segundo, y mas grave: los valores en Rust son movibles por defecto. Si construyes el struct en una direccion y luego lo mueves —al devolverlo, al meterlo en un Vec—, datos viaja a una nueva direccion pero vista seguiria apuntando a la vieja, ya invalida. El borrow checker no prohibe la autorreferencia por pedanteria: la prohibe porque el modelo de moves de Rust la volveria insegura en el instante siguiente.

Su version cotidiana es devolver una referencia a un temporal o a un local, el clasico E0515. Es el mismo fallo —una referencia que sobrevive al dato que la sostiene— y su cura es siempre la misma direccion: dejar de prestar y poseer.

fn mal() -> &String {
    let local = String::from("efimero");
    // &local     // ERROR E0515: el local muere al terminar la funcion
    unreachable!()
}

fn bien() -> String {
    String::from("devuelvo el dueno, sin referencia que pueda colgar")
}

Patrones sin referencias: indices y arenas

La estrategia mas robusta es la mas radical: no usar referencias en absoluto. Si el problema es que una referencia cuelga, sustituyela por algo que no pueda colgar —un indice—. En un grafo o un arbol, en vez de que cada nodo apunte a otro con &Nodo, guardas todos los nodos en un Vec<Nodo> y los enlazas con usize. Los indices no tienen lifetime, no se invalidan al mover el Vec, y rompen los ciclos sin drama.

struct Arbol { nodos: Vec<Nodo> }
struct Nodo { valor: i32, hijo: Option<usize> }   // indice, no referencia: nunca cuelga

impl Arbol {
    fn valor_del_hijo(&self, i: usize) -> Option<i32> {
        self.nodos[i].hijo.map(|h| self.nodos[h].valor)
    }
}

El punto debil de los indices desnudos es que un indice viejo puede apuntar a una ranura reutilizada tras un borrado —el problema ABA—. La solucion madura son los indices generacionales: cada ranura lleva un contador de generacion, y el indice guarda la generacion que vio; si no coinciden, el acceso falla en vez de leer datos ajenos. Crates como slotmap y slab implementan justo esto y son el estandar para grafos mutables.

Cuando de verdad necesitas referencias entre nodos —no indices—, la respuesta es la arena: un asignador que reserva todos los objetos con un lifetime comun 'arena. Como todas las referencias comparten esa region, pueden apuntarse entre si libremente, incluso en ciclos, y se liberan todas de golpe al caer la arena. Crates como bumpalo y typed-arena te dan exactamente esto.

// Con typed-arena: todos los nodos comparten 'arena, asi que las referencias mutuas son legales.
// let arena = typed_arena::Arena::new();
// let a: &Nodo = arena.alloc(Nodo::nuevo());
// let b: &Nodo = arena.alloc(Nodo::nuevo());
// a y b viven ambos en 'arena; pueden referenciarse sin que ninguno cuelgue antes que el otro.

Cuando necesitas la autorreferencia de verdad: Pin y los crates

A veces no puedes evitarla: la propia async de Rust genera futuros autorreferenciales, porque un .await guarda referencias a variables locales que viven en el mismo futuro. La respuesta del lenguaje fue Pin: un envoltorio que garantiza que el valor no se movera de su direccion, eliminando de raiz la mitad insegura del problema (la invalidacion al mover). Pin no te deja escribir la referencia interna en seguro —eso sigue requiriendo unsafe—, pero da el cimiento sobre el que construir autorreferencia correcta, y es la razon de que async funcione sin coste oculto.

Para el caso general, la comunidad encapsula ese unsafe en crates que ofrecen una API segura:

🐍

ouroboros / self_cell

Macros que construyen un struct autorreferencial correcto: guardan el dueno y las vistas juntos, garantizan el no-movimiento y exponen accesores seguros.

🧬

yoke

Une un dato deserializado con el buffer del que presta (zero-copy), atando ambos en un solo valor movible y seguro.

🔗

Rc / RefCell / Weak

Grafos con propiedad compartida y mutabilidad interior; Weak rompe los ciclos para no fugar memoria. El coste es conteo en runtime.

El patron Rc/RefCell/Weak merece verse escrito, porque es la via directa para un grafo con ciclos como un arbol con punteros al padre: los hijos se poseen con Rc, pero la vuelta al padre usa Weak para no cerrar un ciclo de conteos que nunca llegaria a cero.

use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Nodo {
    valor: i32,
    padre: RefCell<Weak<Nodo>>,       // Weak: no suma al conteo fuerte, rompe el ciclo
    hijos: RefCell<Vec<Rc<Nodo>>>,    // Rc: el padre si posee a sus hijos
}
// El padre mantiene vivos a los hijos (Rc); el hijo solo observa al padre (Weak).
// Sin el Weak, padre e hijo se sostendrian mutuamente y jamas se liberarian: fuga.
flowchart TB
p[Necesito referencias que se cruzan o hacia mi mismo] --> q1[Puedo usar indices en vez de referencias?]
q1 -->|si| idx[Vec mas usize; slotmap para indices generacionales]
q1 -->|no| q2[Comparten un mismo ambito de vida?]
q2 -->|si| ar[Arena con lifetime comun: bumpalo o typed-arena]
q2 -->|no| q3[Propiedad compartida con posibles ciclos?]
q3 -->|si| rc[Rc y RefCell con Weak para cortar ciclos]
q3 -->|autorreferencia real| pin[Pin mas unsafe, o crate ouroboros o self_cell]
⚠️
Evita owning_ref y rental: elige los mantenidos

No todos los crates de este espacio envejecieron bien. owning_ref arrastra agujeros de solidez conocidos y rental esta abandonado; ninguno es buena eleccion en codigo nuevo. Los sucesores mantenidos son ouroboros y self_cell para autorreferencia general, yoke para el caso zero-copy de deserializacion, y slotmap/slab para grafos por indice. Antes de adoptar cualquier crate que prometa “structs autorreferenciales faciles”, comprueba su estado de mantenimiento y su historial de solidez: aqui, un unsafe mal encapsulado es memoria corrupta, no un warning.

La autorreferencia es dura porque una referencia es una direccion, y Rust mueve direcciones

Detente en la raiz del problema, porque revela una tension fundamental del diseno de Rust que ningun crate elimina, solo esquiva. Una referencia, por debajo, es una direccion de memoria. Y el modelo de valores de Rust se apoya en una decision temprana y radical: los moves son baratos y ubicuos, un move es un memcpy de bits sin ejecutar codigo, y el compilador se reserva el derecho de mover cualquier valor a otra direccion cuando le convenga. Estas dos verdades —una referencia es una direccion, los valores cambian de direccion libremente— son individualmente virtuosas y conjuntamente incompatibles con la autorreferencia. Si un valor contiene una direccion hacia si mismo y luego se copia bit a bit a otro sitio, la direccion interna sigue apuntando al sitio viejo: memoria colgante fabricada por la operacion mas inocente del lenguaje. Por eso ningun juego de anotaciones de lifetime puede rescatar el caso: el problema no es que falte sintaxis para nombrar la region, es que la semantica de move destruye el invariante en cuanto el valor se mueve. Comprender esto reordena todo el catalogo de soluciones, que deja de ser una lista de trucos y se revela como cuatro formas distintas de romper una de las dos verdades en conflicto. Los indices renuncian a que una referencia sea una direccion: un usize no es un puntero, sobrevive a cualquier move porque no apunta a memoria, apunta a una posicion logica. Las arenas renuncian a mover: todo vive quieto en el mismo bloque hasta que el bloque entero muere, asi que ninguna direccion interna se invalida jamas. Pin renuncia explicitamente a mover un valor concreto, y sobre esa promesa —“este no se movera”— reconstruye la autorreferencia segura que async necesitaba. Rc renuncia a la propiedad unica y estable, difiriendo a runtime la gestion de vidas que el checker no puede probar estaticamente. No hay una quinta via porque solo hay dos verdades que romper. Esta es la marca del pensamiento de sistemas maduro: cuando choques con un muro que parece arbitrario, no busques el conjuro que lo derribe, busca cuales de tus supuestos son mutuamente incompatibles, porque toda solucion sera necesariamente la renuncia a uno de ellos. Rust no te prohibe la autorreferencia por crueldad; te obliga a hacer explicita, en el tipo que eliges, cual de las dos verdades estas dispuesto a sacrificar. Y esa honestidad forzada —tener que decir “aqui uso indices” o “aqui fijo con Pin”— es exactamente lo que hace que el codigo resultante sea legible y correcto donde en otros lenguajes seria un puntero silencioso a la espera de colgar.

📝
Lo esencial de los casos dificiles

Un struct autorreferencial es imposible en Rust seguro por dos motivos que se suman: no puedes nombrar la region de tu propio campo, y los moves invalidarian la referencia interna. Devolver una referencia a un temporal (E0515) es el mismo fallo; se cura poseyendo en vez de prestar. El catalogo de soluciones son renuncias: indices en un Vec (o generacionales con slotmap) renuncian a que la referencia sea una direccion; arenas (bumpalo, typed-arena) dan un lifetime comun y renuncian a mover; Rc/RefCell con Weak difieren la gestion de vidas a runtime; Pin fija el valor y sostiene la autorreferencia real que usa async. Para encapsular el unsafe, usa ouroboros o self_cell (y yoke para zero-copy); evita owning_ref y rental.

⚔️ Sortea la autorreferencia con el patron correcto
  1. Intenta escribir el struct AutoRef { datos: String, vista: &str } y articula, con tus palabras, las dos razones exactas por las que no compila.
  2. Reproduce el E0515 devolviendo &local desde una funcion y arreglalo devolviendo el String por valor; explica que renuncia hiciste.
  3. Modela un arbol con Vec<Nodo> e indices usize en lugar de referencias, y razona por que ningun indice cuelga al crecer el Vec.
  4. Investiga slotmap y explica que problema (ABA) resuelven los indices generacionales que los usize desnudos no.
  5. Elige, para cada escenario, el patron adecuado y justifica la renuncia que implica: un grafo mutable de miles de nodos; dos nodos que deben referenciarse mutuamente; un futuro async que presta un local a traves de un .await; un dato deserializado que presta de su buffer de origen.