wandres.dev
PIN Y AUTO-REFERENCIAS · estructuras que se apuntan a sí mismas

Structs auto-referenciales: imposibles de nombrar, y por qué async los fabrica

Un struct que guarda una referencia a otro de sus propios campos no se puede expresar en Rust seguro: no existe un lifetime que nombre me presto a mí mismo, y el borrow checker lo rechaza. Sin embargo, la transformación de `async fn` en una máquina de estados genera exactamente esa forma cada vez que una referencia cruza un punto `await`. El caso raro resulta ser el caso central del async.

⏱ 20 min

La lección anterior mostró qué le pasa a un valor auto-referencial cuando lo mueves: se rompe. La pregunta natural es entonces por qué el compilador te deja construir semejante bomba. La respuesta, sorprendente, es que en Rust seguro no te deja: intentar escribir un struct que guarde una referencia a otro de sus campos choca contra un muro de lifetimes que no hay forma de sortear. No es una prohibición explícita con un mensaje del tipo auto-referencia no permitida; es algo más profundo: la auto-referencia es innombrable en el sistema de lifetimes. Y aquí está el giro que justifica todo el nivel: esa forma que el lenguaje no te deja escribir a mano es exactamente la que el compilador genera, por ti y a tus espaldas, cada vez que compilas una función async que sostiene una referencia a través de un await.

🎯 Al terminar esta lección sabrás
  • Intentar escribir un struct auto-referencial y leer por qué el borrow checker lo rechaza.
  • Entender que el obstáculo no es una regla ad hoc, sino la imposibilidad de nombrar el lifetime.
  • Ver cómo un async fn se compila a una máquina de estados que guarda sus locales.
  • Descubrir que una referencia viva a través de un await convierte esa máquina en auto-referencial.

El struct que no se puede escribir

Intentémoslo directamente. Un struct que posee un String y guarda, junto a él, una &str que apunta a una porción de ese mismo String:

struct Autoref {
    dato: String,
    vista: &str,   // pretende apuntar a una parte de self.dato
}

No compila, y el primer error es que vista necesita un lifetime: &str es siempre &'a str para algún 'a. Así que te preguntas cuál. ¿El lifetime del Autoref? Pero el Autoref contiene al String; su duración es la del propio struct que estás definiendo. Necesitarías escribir algo como me presto a mí mismo mientras yo exista, y ese lifetime no tiene nombre en Rust. Cualquier 'a que intentes poner será un parámetro externo, ligado a algo de fuera del struct, nunca al dato de dentro.

struct Autoref<'a> {
    dato: String,
    vista: &'a str,   // 'a viene de fuera; no puede ser el propio dato
}
// Y aunque lo declares, construirlo es imposible en seguro:
// necesitarias prestar `dato` a `vista` DENTRO del mismo constructor,
// creando un prestamo que dura tanto como el valor que lo contiene.

El borrow checker rechaza construirlo porque tendrías que tomar prestado dato para inicializar vista en el mismo instante en que el valor todavía se está formando, y ese préstamo tendría que vivir tanto como el struct entero —un préstamo de un valor por parte de sí mismo—. Las reglas de préstamo no admiten esa figura. No es que Rust prohíba la auto-referencia: es que carece de vocabulario para expresarla de forma segura. La seguridad de los lifetimes se compra al precio de que ciertas estructuras válidas se vuelvan inefables.

El intento de construirlo lo deja crudo: el momento en que dato entra en el struct es un move, y ese move invalidaría la vista que acabas de crear.

fn construir() -> Autoref {          // ¿que lifetime tendria el campo vista?
    let dato = String::from("hola mundo");
    let vista = &dato[0..4];         // vista presta de dato, que vive en el stack local...
    Autoref { dato, vista }          // ...pero `dato` SE MUEVE al struct: vista quedaria colgante
}                                    // ERROR: no se puede devolver un prestamo de un local
⚠️
Las salidas existen, pero todas salen de lo seguro

Se pueden construir valores auto-referenciales, pero no con referencias normales. Las tres vías son: punteros crudos (*const T, *mut T) envueltos en unsafe, donde tú cargas con la promesa de que el valor no se moverá; crates como ouroboros o self_cell, que esconden ese unsafe tras una API segura; o dejar que el compilador genere la estructura por ti, que es lo que hace async. Las tres comparten un rasgo: renuncian a las referencias comprobadas por el borrow checker y asumen a mano la invariante de inmovilidad. Justo la invariante que Pin formaliza.

async fn es una máquina de estados

Aquí converge todo. Una función async no se ejecuta cuando la llamas: devuelve un Future, un valor que representa el cálculo suspendible. Y ese Future lo fabrica el compilador transformando el cuerpo de tu función en una máquina de estados: un enum implícito con una variante por cada tramo entre puntos await, que guarda en sus campos todas las variables locales que deben sobrevivir a una suspensión.

async fn saludar() -> usize {
    let dato = String::from("hola mundo");   // local
    let vista = &dato[0..4];                 // referencia a otro local
    esperar_un_tick().await;                 // SUSPENSION: se guarda el estado
    println!("{vista}");                     // vista se usa DESPUES del await
    vista.len()
}

Piensa qué debe recordar la máquina de estados al suspenderse en el await. Cuando saludar se aparque ahí y devuelva el control al ejecutor, tiene que conservar dato —porque println! lo usará después a través de vista— y tiene que conservar vista —porque también se usa después—. Pero vista es &dato[0..4]: una referencia a otro campo de la misma máquina de estados. El Future generado tiene, conceptualmente, esta forma:

// Aproximacion de lo que el compilador genera:
struct FuturoSaludar {
    dato: String,          // el buffer, dueño
    vista: *const u8,      // apunta DENTRO de self.dato: auto-referencia
    // ...estado de en que punto del await estamos
}

Ahí está, otra vez, el Autoref de la primera lección —el struct innombrable— salvo que esta vez no lo escribiste tú: lo generó el compilador. Y no fue un caso rebuscado: bastó con hacer lo más natural del mundo en código async, sostener una referencia a un dato local a través de un await. La forma que el lenguaje te prohíbe escribir a mano es la que produce a destajo en cuanto usas async de verdad.

Con más precisión, la máquina de estados es un enum con una variante por cada tramo entre puntos await, y cada variante guarda solo los locales que deben sobrevivir a esa suspensión:

// Aproximacion de lo que el compilador genera para `saludar`:
enum FuturoSaludar {
    Inicio,                     // aun no se ha ejecutado nada
    TrasEsperar {               // suspendido en el punto await
        dato: String,           // el buffer, dueño
        vista: *const u8,       // apunta DENTRO de self.dato: auto-referencia
    },
    Terminado,                  // ya produjo su Output
}
💡
Solo se guarda lo que cruza un await

Un local que nace y muere dentro de un mismo tramo, sin cruzar ningún await, no aparece en la máquina de estados: vive y se destruye durante un único poll, como en una función normal. Solo los valores que siguen vivos a través de una suspensión deben persistirse en el futuro. Por eso la auto-referencia surge exactamente cuando una referencia a un local se usa después de un await: es la condición que fuerza a guardar juntos el dueño y el puntero a su interior.

Por qué esto obliga a introducir Pin

La consecuencia encadena con la lección anterior de forma inevitable. Ese FuturoSaludar contiene un puntero a uno de sus propios campos; por tanto, si lo mueves después de haber empezado a ejecutarlo, el puntero vista quedará colgante, apuntando a donde dato estaba antes del move. Pero un Future está hecho para ser sondeado (poll) muchas veces por un ejecutor, con suspensiones en medio. Si entre dos sondeos el ejecutor pudiera mover el futuro —guardarlo en un Vec que realoja, pasarlo por valor, intercambiarlo—, lo corrompería.

De modo que el Future auto-referencial impone una regla dura: una vez que empieza a ejecutarse, no debe moverse jamás. Necesitamos una manera de expresar esa regla en el sistema de tipos, que el ejecutor esté obligado a respetar y que el compilador verifique. Esa manera es Pin, y por eso Future::poll no recibe un &mut self corriente sino un Pin<&mut Self>. La lección tres construye ese contrato desde cero.

flowchart TB
A[async fn saludar] -->|el compilador transforma| B[Maquina de estados FuturoSaludar]
B --> C[Campo dato el String propietario]
B --> D[Campo vista puntero dentro de dato]
D -->|apunta a| C
C --> E[Es auto-referencial]
D --> E
E --> F[Moverlo tras empezar deja vista colgante]
F --> G[Regla no moverse una vez sondeado]
style B fill:#cba6f7,color:#11111b
style E fill:#f9e2af,color:#11111b
style F fill:#f38ba8,color:#11111b
style G fill:#a6e3a1,color:#11111b
Rust movió la solución de capa: del lenguaje a un tipo de biblioteca

Lo que acabas de ver es una de las decisiones de diseño más elegantes de Rust, y conviene articularla con precisión. El lenguaje se enfrentaba a una tensión aparentemente irresoluble. Por un lado, la auto-referencia es innombrable en el sistema de lifetimes, y con razón: permitir un lifetime que diga me presto a mí mismo obligaría a extender el borrow checker de formas que probablemente romperían su capacidad de razonar sobre todo lo demás. Por otro lado, la transformación async —una de las características más importantes del lenguaje moderno— necesita generar valores auto-referenciales, porque una referencia que cruza un await no tiene otra forma. Añadir auto-referencia segura al núcleo del lenguaje habría sido carísimo; prohibir el async con referencias vivas lo habría vuelto inútil. Rust escogió una tercera vía que ni toca los lifetimes ni prohíbe nada: resolvió el problema una capa más arriba, en un tipo de biblioteca. En lugar de enseñar al lenguaje a nombrar la auto-referencia, mantuvo el núcleo simple y creó Pin, un envoltorio que no da acceso a las operaciones que mueven un valor. La auto-referencia sigue siendo innombrable y el move sigue siendo un memcpy ciego; lo único que cambia es que, para los pocos valores que lo necesitan, el sistema de tipos garantiza que nadie los moverá, y por tanto sus punteros internos —puestos con unsafe por el generador de futuros— permanecen válidos. Es una lección de arquitectura que trasciende Pin: cuando una garantía es demasiado cara de hornear en el lenguaje, a menudo se puede expresar como un tipo que retira una capacidad. El compilador no aprendió nada nuevo sobre auto-referencias; simplemente confía en un tipo que promete inmovilidad. Esa es la diferencia entre un lenguaje que crece sin control y uno que empuja la complejidad hacia sus bordes, donde solo la paga quien la necesita.

📝
Lo esencial de la auto-referencia

Un struct con una referencia a otro de sus campos no se puede expresar en Rust seguro: no existe un lifetime para me presto a mí mismo, y el borrow checker rechaza construir un préstamo que dure tanto como el valor que lo contiene. La auto-referencia no está prohibida, es innombrable. Sin embargo, un async fn se compila a una máquina de estados que guarda sus locales, y en cuanto una referencia sobrevive a un await, esa máquina pasa a contener un puntero a uno de sus propios campos: es auto-referencial por construcción. Como se sondea repetidas veces, no debe moverse una vez empezada, y esa regla exige un mecanismo del sistema de tipos: Pin.

⚔️ Fabrica lo innombrable
  1. Escribe el struct Autoref con dato: String y vista: &str, intenta compilarlo y transcribe el error de lifetime; explica qué lifetime te está faltando.
  2. Añade un parámetro <'a> y vista: &'a str; argumenta por qué, aun declarándolo, no puedes construir el valor en Rust seguro.
  3. Escribe un async fn que tenga una variable local prestada a otra referencia local viva a través de un await, e identifica qué dos cosas debe guardar la máquina de estados.
  4. Dibuja el Future generado como un struct con un campo dueño y un campo puntero, y marca la flecha de auto-referencia.
  5. Enlaza con la lección anterior: explica qué le pasaría a ese Future si el ejecutor lo moviera entre dos poll, y por qué eso motiva la existencia de Pin.