Cotas de lifetime: `T: 'a` (outlives) y `'a: 'b` (subtipado)
Dos formas gramaticales con el mismo `:` pero mundos distintos. `T: 'a` ata un tipo a una region: T sobrevive a 'a, y de ahi sale la buena formacion de `&'a T`. `'a: 'b` ordena dos regiones: 'a sobrevive a 'b, y de ahi sale el subtipado covariante de referencias. Son los dos atomos sobre los que el borrow checker construye todo lo demas.
Hasta ahora un lifetime era una marca solitaria sobre una referencia: &'a T. Este nivel empieza cuando esas marcas dejan de vivir aisladas y se relacionan. Aparecen dos formas nuevas que comparten el mismo : pero pertenecen a mundos distintos: T: 'a liga un tipo a una region —“el tipo T sobrevive a ’a”—, y 'a: 'b ordena dos regiones —“’a sobrevive a ’b”—. La primera gobierna cuando una referencia esta bien formada; la segunda, cuando una referencia larga puede usarse donde se esperaba una corta. Domina estas dos cotas y habras visto la aritmetica cruda que el borrow checker resuelve por ti en cada compilacion.
- Leer
T: 'acomo “T sobrevive a la region ’a” y derivar de ahi la buena formacion de&'a T. - Leer
'a: 'bcomo “’a sobrevive a ’b” y conectarlo con el subtipado y la covarianza de las referencias. - Distinguir cuando el compilador infiere estas cotas (implied bounds, inferencia de outlives) y cuando debes escribirlas a mano.
- Usar
where 'a: 'ben una firma para autorizar la coercion de la region larga a la corta.
T: 'a: la cota de “sobrevivir a una region”
La cota T: 'a se lee “T sobrevive a ’a” (en ingles, T outlives ’a). Su significado formal es preciso: todo lifetime que aparezca dentro del tipo T es al menos tan largo como ’a. Dicho de otro modo, un valor de tipo T sigue siendo valido durante toda la region ’a, porque ninguna referencia escondida en su interior caduca antes.
De aqui nace una de las reglas mas fundamentales del lenguaje, la buena formacion de las referencias: el tipo &'a T solo esta bien formado si T: 'a. La intuicion es inevitable. Si tienes una referencia valida durante ’a, el dato al que apunta debe seguir vivo durante ’a; y si ese dato fuera a su vez un tipo con una referencia interna mas corta, seguir la referencia externa podria alcanzar memoria ya liberada. La cota prohibe justo eso.
// i32 no contiene referencias: sobrevive a cualquier region, incluso 'static.
fn _a<'a>(_x: &'a i32) {} // exige i32: 'a -> trivialmente cierto
// Un tipo que guarda una referencia 'b solo sobrevive a 'a si 'b: 'a.
struct Vista<'b> { s: &'b str }
fn _b<'a, 'b>(_v: &'a Vista<'b>) {} // el compilador asume Vista<'b>: 'a, es decir 'b: 'a
Los tipos duenos de sus datos cumplen la cota mas fuerte, T: 'static: un String, un Vec<u8> o un i32 no dependen de ningun prestamo efimero, asi que sobreviven a cualquier region. Solo la incumplen los tipos que guardan una vista prestada, como Vista<'b>, cuya supervivencia queda encadenada a la de su 'b.
Lee siempre el : como “sobrevive a”. En T: 'a, “el tipo T sobrevive a la region ’a”. En 'a: 'b, “la region ’a sobrevive a ’b”. Y recuerda quien queda a la izquierda: el superviviente, el mas largo, el que contiene al otro. Por eso 'static esta a la izquierda de todos —'static: 'a para toda ’a—: nada le sobrevive por encima. Si alguna vez dudas de la direccion, preguntate “quien tiene que durar mas para que esto sea seguro?”: ese va a la izquierda del :.
'a: 'b: ordenar dos regiones y el subtipado que esconde
La cota 'a: 'b se lee “’a sobrevive a ’b”: la region ’a es al menos tan larga como ’b y la contiene por completo. Cuidado con la direccion, que es la trampa clasica: en 'a: 'b, la region larga es 'a (la que esta a la izquierda del :) y la corta es 'b. El : se lee siempre “sobrevive a”.
Esta relacion es lo que hace posible el subtipado de lifetimes. Cuando 'a: 'b, el tipo &'a T es un subtipo de &'b T, y por tanto una referencia larga puede usarse en cualquier sitio donde se espere una corta. Es lo que ocurre cada vez que pasas un &'static str a una funcion que pide un &str cualquiera: 'static sobrevive a toda region, luego encaja en todas. Las referencias son covariantes en su lifetime: alargar la region de lo que exiges es siempre seguro; acortarla, no.
// 'a: 'b => la region 'a contiene a 'b => &'a str encaja donde se pide &'b str.
fn elige<'a, 'b>(largo: &'a str, _corto: &'b str) -> &'b str
where
'a: 'b, // 'a sobrevive a 'b: autoriza devolver el largo como corto
{
largo // coercion covariante &'a str -> &'b str
}
Sin la clausula where 'a: 'b, esa funcion no compila: el compilador no puede devolver un &'a str donde se prometio un &'b str salvo que tenga garantia de que ’a incluye a ’b. La cota se la da. Observa que 'static: 'a se cumple para toda ’a por definicion —el programa entero contiene a cualquier region—, y por eso 'static es el subtipo universal.
Esa coercion tiene un solo sentido, y la asimetria es fundamental: las referencias compartidas son covariantes en su lifetime —puedes sustituir una region por otra mas corta, jamas al reves—. Intentar lo contrario, usar un &'b str corto donde se pide un &'a str largo, es exactamente lo que el sistema debe prohibir, porque dejaria vivo un nombre para un dato ya muerto.
fn necesita_corto<'b>(_r: &'b str) {}
fn demo<'a>(largo: &'a str) {
necesita_corto(largo); // OK: &'a str se coacciona a &'b str mas corto (covariante)
}
La covarianza se rompe bajo mutabilidad: &mut T es invariante en T. Si &mut &'a str pudiera coaccionarse a &mut &'b str, alguien podria escribir a traves de esa referencia un &'b str efimero en una ranura que el resto del programa cree que guarda un &'a str duradero; al leerla despues, referencia colgante. La invarianza de &mut es, literalmente, la que cierra esa puerta —y por eso la variancia no es un tecnicismo academico, sino parte del andamiaje de seguridad.
T: 'a · tipo sobre region
Relaciona un tipo con una region. Verdadero cuando toda referencia interna de T dura al menos ’a. Es la condicion de buena formacion de &'a T.
'a: 'b · region sobre region
Relaciona dos regiones. Verdadero cuando ’a contiene a ’b. Habilita la coercion covariante &'a T hacia &'b T.
Quien escribe estas cotas: inferencia frente a anotacion
La razon de que rara vez veas T: 'a escrito a mano es que el compilador lo infiere en casi todas partes. Dentro de una funcion actuan los implied bounds: si un parametro tiene tipo &'a T, el cuerpo puede asumir T: 'a sin declararlo, porque el propio tipo del argumento seria mal formado de otro modo. Y desde la RFC 2093 (inferred outlives), las definiciones de struct y enum tampoco necesitan la cota: se deduce de los campos.
// Rust moderno: NO hace falta escribir T: 'a. Se infiere del campo.
struct Ref<'a, T> { r: &'a T }
// Antes de la RFC 2093 el compilador exigia la cota explicita:
// struct Ref<'a, T: 'a> { r: &'a T }
Entonces, cuando si debes escribirlas? En los sitios donde no hay campo ni argumento del que inferirlas: al relacionar dos lifetimes nombrados en una firma (where 'a: 'b, como arriba), en definiciones y cotas de traits, en tipos asociados y GATs (generic associated types), y a veces junto a PhantomData cuando modelas variancia a mano. En esos contextos la cota deja de ser ruido y se vuelve la unica forma de expresar la relacion temporal que tu diseno exige.
flowchart TB s[static: el techo, todo el programa] --> a[region a: mas larga] a --> b[region b: contenida en a] b --> e[expresion: region minima] note[a colon b significa a sobrevive a b: la flecha baja de lo que contiene a lo contenido]
No confundas las dos formas por compartir simbolo. En T: 'a, a la izquierda hay un tipo y a la derecha una region: es una cota de buena formacion. En 'a: 'b, a ambos lados hay regiones: es una relacion de orden entre ellas. Un truco para no equivocarte de direccion en 'a: 'b: piensa que quien esta a la izquierda del : es el “mayor” (sobrevive), y quien esta a la derecha es el “menor” (es sobrevivido). 'static esta siempre a la izquierda de todos porque a nadie sobrevive por encima de el.
Aqui esta la revelacion que unifica todo el nivel y buena parte de Rust. El comprobador de prestamos no “entiende” tu codigo en ningun sentido semantico: lo traduce a un sistema de restricciones sobre una unica relacion, la de outlives (sobrevivir), y despues comprueba si ese sistema tiene solucion. Y las restricciones atomicas de ese sistema son, exactamente, las dos que acabas de ver. T: 'a es la restriccion “este tipo no ancla nada por debajo de ’a”. 'a: 'b es la restriccion “esta region contiene a esta otra”. Todo lo demas es construccion sobre esos dos atomos. La variancia que estudiaste como covarianza de &'a T es simplemente como se propagan las restricciones 'a: 'b a traves de los constructores de tipo. La elision de la leccion 2 es rellenar las restricciones evidentes para que no las escribas. Los HRTB de la leccion 3 son cuantificar universalmente sobre estas restricciones. El 'static de la leccion 4 es el elemento maximo del orden. Incluso el salto de NLL a Polonius es un cambio en como se resuelve el mismo sistema, no en cual es. Por eso un ingeniero experto no lee T: 'a ni 'a: 'b como sintaxis que memorizar, sino como las dos preguntas que el compilador se hace sin cesar: dado este dato, sobrevive lo suficiente el tipo? contiene esta region a aquella? Cuando interiorizas que los lifetimes son un algebra de regiones ordenadas por inclusion y que estas dos cotas son sus operaciones primitivas, los mensajes del checker dejan de parecer caprichos y se leen como lo que son: renglones de una demostracion que a el le faltaba un axioma para cerrar. Tu trabajo, cuando anotas, no es “pelear con el compilador”: es aportarle el axioma que no podia inferir.
T: 'a significa “T sobrevive a ’a”: toda referencia interna de T dura al menos ’a, y esa es la condicion para que &'a T este bien formado; los tipos duenos (como String) cumplen 'static. 'a: 'b significa “’a sobrevive a ’b”: ’a contiene a ’b, y eso habilita la coercion covariante de &'a T a &'b T —por eso un &'static str encaja donde se pide cualquier &str—. El compilador infiere T: 'a casi siempre (implied bounds, RFC 2093); tu escribes las cotas al relacionar dos lifetimes nombrados o en traits, tipos asociados y GATs. Ambas son restricciones atomicas del sistema de outlives que resuelve el borrow checker.
- Escribe
fn f<'a, 'b>(x: &'a str, y: &'b str) -> &'b strque devuelvax; comprueba que no compila y arreglalo anadiendowhere 'a: 'b. Explica por que la direccion de la cota es esa y no la contraria. - Define
struct Par<'a, 'b> { a: &'a str, b: &'b str }sin escribir cotas de outlives y confirma que compila gracias a la inferencia de la RFC 2093; razona que cotas dedujo el compilador. - Pasa un
&'static stra una funcion que pide&'short stry explica, en terminos de'static: 'short, por que la coercion es segura. - Intenta una coercion en direccion contraria (usar un
&'short strdonde se pide&'long str) y lee el error; conecta el fallo con que las referencias son covariantes, no contravariantes, en su lifetime. - Escribe una funcion generica
fn g<'a, T>(x: &'a T)y usa dentro un contexto que exijaT: 'a; comprueba que no necesitas declararlo por los implied bounds, y luego rompe esa suposicion para ver el error.