wandres.dev
LIFETIMES AVANZADOS · los casos difíciles

HRTB: `for<'a>`, o cuantificar sobre todos los lifetimes

Cuando una cota debe valer no para un lifetime concreto sino para cualquiera que elija quien llame, aparece `for<'a>`: el cuantificador universal sobre regiones. Vive escondido en toda cota `Fn(&T)` por azucar de elision, y se vuelve explicito en punteros a funcion, traits con lifetime y closures que devuelven referencias. La diferencia entre `<'a, F: Fn(&'a T)>` y `F: for<'a> Fn(&'a T)`.

⏱ 18 min

Todas las cotas que has visto fijan un lifetime concreto: <'a, F: Fn(&'a T)> dice “existe una region ’a, elegida por quien llama a la funcion externa, para la que F funciona”. Pero hay un patron que esa forma no puede expresar: una cota que debe valer para cualquier region, incluida una que aun no existe porque la elegira el propio cuerpo mas tarde. Para eso Rust tiene los higher-ranked trait bounds, escritos for<'a>: el cuantificador universal sobre lifetimes. Se lee “para todo ’a”. Vive escondido en casi toda cota Fn(&T) gracias al azucar de elision, y emerge explicito en cuanto un closure toma referencias cuya region no puedes nombrar desde fuera. Entenderlo es la diferencia entre firmas que aceptan cualquier closure y firmas que rechazan los correctos con mensajes cripticos.

🎯 Al terminar esta lección sabrás
  • Leer for<'a> Fn(&'a T) como “para todo lifetime ’a, F implementa Fn(&'a T)”, y distinguirlo de fijar un 'a concreto.
  • Reconocer que Fn(&str) desazucara a for<'a> Fn(&'a str), de modo que los HRTB suelen ser implicitos.
  • Escribir for<'a> explicito cuando debes relacionar la region del argumento con la del retorno o cotas sobre traits con lifetime.
  • Diagnosticar los errores tipicos de HRTB: “implementation is not general enough” y “one type is more general than the other”.

for<'a> frente a un 'a fijo: quien elige la region

La clave de todo es quien elige el lifetime. Compara estas dos firmas, que parecen equivalentes y no lo son:

// (1) 'a lo elige quien llama a `aplica`, ANTES de entrar en el cuerpo. Una region fija.
fn aplica_fijo<'a, F: Fn(&'a i32) -> &'a i32>(f: F, x: &'a i32) -> &'a i32 { f(x) }

// (2) F debe valer para CUALQUIER 'a; el cuerpo puede llamar a f con regiones distintas.
fn aplica_hrtb<F: for<'a> Fn(&'a i32) -> &'a i32>(f: F) {
    let a = 10;
    let b = 20;
    let _ra = f(&a);   // una region
    let _rb = f(&b);   // otra region distinta, mas corta
}

En (1) hay un 'a, escogido en el momento de la llamada; el closure solo tiene que funcionar para esa region concreta. En (2), for<'a> exige que f funcione para toda region imaginable, porque el cuerpo va a invocarlo con prestamos locales cuya vida ni siquiera existe cuando se llama a aplica_hrtb. La version fija no serviria: no hay ningun 'a externo que abarque a la vez la region de a y la de b. El cuantificador universal es la unica forma de decir “sea cual sea la referencia que yo te pase luego, tu sabras manejarla”.

El azucar que lo esconde: por que casi nunca lo escribes

Si los HRTB fueran tan necesarios, deberias escribirlos a todas horas. No lo haces porque el compilador desazucara las cotas Fn con lifetimes elididos hacia HRTB de forma automatica. Estas tres lineas son idenficas:

fn f1<F: Fn(&str) -> &str>(_f: F) {}
fn f2<F: for<'a> Fn(&'a str) -> &'a str>(_f: F) {}   // lo que f1 significa de verdad
// f1 y f2 son la MISMA cota; el azucar de elision escribe el for<'a> por ti.

Por eso una funcion como fn cada<F: Fn(&str)>(f: F) acepta sin drama closures que reciben referencias de cualquier procedencia: por debajo, la cota ya es for<'a> Fn(&'a str). El azucar cubre el caso comun —una region que entra, una region que sale, ambas ligadas— y solo necesitas el for<'a> explicito cuando quieres una relacion que la elision no adivina, o cuando el HRTB no esta sobre un Fn sino sobre un trait tuyo con parametro de lifetime.

// HRTB explicito sobre un trait propio con lifetime: "para todo 'a, T sabe analizar &'a str".
trait Analiza<'a> { fn parse(&self, s: &'a str) -> usize; }

fn usar<T>(t: T) where T: for<'a> Analiza<'a> {
    // t vale para regiones distintas dentro de este cuerpo, no para una fija de fuera.
    let _ = t.parse("uno");
    let _ = t.parse("dos");
}

Donde emergen en la vida real: punteros a funcion, closures y serde

Los HRTB dejan de ser teoria en tres frentes. El primero son los punteros a funcion que toman referencias: su tipo es literalmente higher-ranked.

// El tipo de esta funcion es for<'a> fn(&'a i32) -> &'a i32, higher-ranked de nacimiento.
fn id(x: &i32) -> &i32 { x }
let _p: for<'a> fn(&'a i32) -> &'a i32 = id;

El segundo son los closures que devuelven referencias derivadas del argumento, el terreno donde el compilador a veces necesita ayuda para inferir el HRTB y aparecen los errores mas oscuros de Rust: “implementation is not general enough” significa casi siempre que tu closure solo vale para un lifetime concreto cuando la cota pedia que valiera para todos. El tercero son bibliotecas como serde: el trait Deserialize<'de> lleva un lifetime, y la cota DeserializeOwned no es mas que for<'de> Deserialize<'de> —“deserializable para cualquier region de entrada”—, lo que permite deserializar sin atarte al buffer de origen.

Cuando choques con “not general enough”, el arreglo casi nunca esta en la cota, sino en ayudar a la inferencia del closure a permanecer generica. La palanca mas fiable es extraerlo a una fn con nombre, que es higher-ranked por construccion y nunca colapsa a un lifetime:

fn aplica<F: for<'a> Fn(&'a str) -> &'a str>(_f: F) {}

fn id(s: &str) -> &str { s }        // higher-ranked por construccion: vale para toda region

fn main() {
    aplica(id);                     // robusto: la fn con nombre satisface el for<'a> sin dudas
    // si un closure equivalente fallara la inferencia, anota sus tipos o usa la fn con nombre
}
🎯

'a fijo: quien llama elige

<'a, F: Fn(&'a T)> fija la region en el borde externo. El closure solo debe valer para esa ’a concreta. Menos flexible.

♾️

for<'a>: vale para todas

F: for<'a> Fn(&'a T) cuantifica sobre todas las regiones. El cuerpo puede invocar f con prestamos locales de vidas diversas.

flowchart TB
q[Cota Fn con referencias] --> a[Escrita con lifetime elidido como Fn de ref str]
a --> d[El compilador desazucara a for de a Fn de ref a str]
d --> u[Universal: el cuerpo llama f con regiones locales distintas]
q --> e[Necesitas relacionar arg y retorno o trait con lifetime?]
e -->|si| x[Escribe for de a explicito]
e -->|no| y[Deja que el azucar lo ponga por ti]
⚠️
'implementation is not general enough': tu closure vale para uno, la cota pide todos

Este error, junto con “one type is more general than the other”, es la firma inconfundible de un desajuste de HRTB. El compilador te esta diciendo: la cota exige for<'a> —funcionar para toda region— pero el tipo concreto que le diste (a menudo un closure cuya inferencia colapso a un lifetime particular) solo funciona para una. La cura habitual no es tocar la cota, sino ayudar a la inferencia del closure a permanecer generica: anotar los tipos de sus parametros, extraerlo a una fn con nombre (que es higher-ranked por construccion), o forzar el HRTB con una funcion identidad auxiliar. El sintoma es sutil pero el diagnostico es siempre el mismo: alguien prometio “para todos” y entrego “para uno”.

El for<'a> es cuantificacion de segundo orden colada en un lenguaje de sistemas

Detente en lo que for<'a> es de verdad, porque es una de las ideas mas hondas del sistema de tipos de Rust y casi nadie la nombra. Una cota ordinaria como T: Clone es una afirmacion de primer orden: “este tipo concreto tiene esta propiedad”. Pero for<'a> Fn(&'a T) no habla de un lifetime concreto: cuantifica universalmente sobre un dominio infinito de regiones, incluidas regiones que todavia no existen y que se materializaran en el futuro del programa. Eso es logica de rango superior —higher-ranked, de ahi el nombre— y su presencia en un lenguaje de sistemas sin recolector de basura es casi milagrosa: te permite escribir una funcion que acepta un callback y garantizar en tiempo de compilacion que ese callback sabra tratar cualquier prestamo que le arrojes despues, sin conocer ninguno de ellos de antemano. Piensa en la asimetria que esto crea. Cuando fijas <'a, F: Fn(&'a T)>, tu, el que define la firma, eliges la region y se la impones al closure; el closure es el debil. Cuando escribes for<'a>, inviertes el poder: es el closure quien debe estar preparado para toda region, y tu cuerpo quien la elige, tarde y libremente. El cuantificador universal desplaza la carga de la prueba del que provee al que consume. Y aqui esta la elegancia final: este poder inmenso viaja casi siempre oculto, porque el azucar de elision escribe el for<'a> por ti en el noventa por ciento de las cotas Fn. La mayoria de los programadores de Rust usan cuantificacion de rango superior a diario sin saberlo, del mismo modo que hablan prosa sin saber que es prosa. El dia que un error de “not general enough” te obliga a escribir for<'a> a mano no estas aprendiendo una sintaxis nueva y arcana: estas viendo, por primera vez desnudo, el andamiaje logico que sostenia en silencio cada callback que habias escrito. La leccion del ingeniero maduro es dejar de temer ese momento y empezar a reconocerlo: cuando el compilador te pide for<'a>, te esta pidiendo que hagas explicita una promesa universal que hasta entonces habias hecho sin darte cuenta.

📝
Lo esencial de los HRTB

for<'a> es el cuantificador universal sobre lifetimes: for<'a> Fn(&'a T) significa “para toda region ’a, F implementa Fn(&'a T)”. Se distingue de <'a, F: Fn(&'a T)>, donde 'a es una region fija elegida en el borde externo: el HRTB deja que el cuerpo elija regiones distintas y locales. Casi siempre es implicito, porque Fn(&str) desazucara a for<'a> Fn(&'a str). Lo escribes explicito para relacionar argumento y retorno de forma no elidible, o en traits con parametro de lifetime (for<'de> Deserialize<'de> es DeserializeOwned). Los errores “implementation is not general enough” y “one type is more general than the other” delatan un closure que solo vale para un lifetime cuando la cota pedia todos.

⚔️ Haz visible el for<'a>
  1. Escribe fn aplica<F: for<'a> Fn(&'a i32) -> &'a i32>(f: F) que llame a f con dos variables locales de vidas distintas, y demuestra que la version con 'a fijo no compila. Explica quien elige la region en cada caso.
  2. Confirma que fn f<F: Fn(&str)>(f: F) y fn f<F: for<'a> Fn(&'a str)>(f: F) son la misma cota escribiendo una que llame a la otra.
  3. Declara una variable de tipo for<'a> fn(&'a i32) -> &'a i32 y asignale una fn con nombre; razona por que las fn con nombre son higher-ranked por construccion.
  4. Provoca deliberadamente el error “implementation is not general enough” pasando un closure cuya inferencia colapse a un lifetime, y arreglalo extrayendolo a una fn con nombre o anotando sus parametros.
  5. Define un trait Analiza<'a> con un metodo que reciba &'a str y escribe una funcion con cota where T: for<'a> Analiza<'a>; usala llamando al metodo con dos literales distintos dentro del mismo cuerpo.