wandres.dev
ASSOCIATED TYPES Y GATS · el sistema de tipos avanzado

Lifetimes asociados y el patrón type Item de a

El GAT sobre lifetimes tiene una forma canónica: type Item<'a>. Antes de existir, se emulaba poniendo el lifetime en el propio trait —trait LendingIterator<'a>—, con consecuencias que contaminaban cada firma. Por qué el lifetime pertenece al tipo asociado y no al trait, cómo se acotan estos tipos desde un genérico con bounds de rango superior for<'a>, y qué papel cumple where Self: 'a.

⏱ 19 min

El patrón type Item<'a> merece un capítulo propio, porque es la forma canónica en que Rust expresa una idea sutil: “un tipo que, dado un lifetime, produce otro tipo relacionado”. Antes de los GATs, los programadores intentaban expresarla poniendo el lifetime en el trait entero —trait LendingIterator<'a>—, y el resultado era un desastre de firmas contaminadas y bounds que no componían. La solución correcta desplaza el lifetime de su lugar equivocado —el trait— a su lugar natural —el tipo asociado—. Entender por qué ese desplazamiento lo cambia todo es entender dónde vive de verdad un lifetime asociado.

🎯 Al terminar esta lección sabrás
  • Escribir el patrón type Item<'a> como forma canónica de un lifetime asociado.
  • Contrastarlo con el antipatrón previo de poner el lifetime en el trait, trait Trait<'a>.
  • Acotar tipos asociados genéricos desde un genérico con bounds de rango superior for<'a>.
  • Precisar el significado y la necesidad de where Self: 'a en estas familias.

El lifetime en el sitio equivocado

Antes de los GATs, para que un next prestara de self había un único truco: colgar el lifetime del trait.

// El antipatron pre-GAT: lifetime en el trait entero
trait PrestaViejo<'a> {
    type Item;
    fn next(&'a mut self) -> Option<Self::Item>;
}

Parece que funciona, pero el lifetime 'a ahora forma parte de la identidad del trait, y eso envenena todo lo que lo toca. Un tipo no implementa PrestaViejo, sino PrestaViejo<'a> para cada 'a, y cualquier función genérica que lo reciba tiene que arrastrar ese 'a en su firma, propagarlo a sus llamadores y ligarlo prematuramente. Peor aún: como &'a mut self fija el préstamo para un 'a concreto elegido por quien llama, el borrow checker suele concluir que self queda prestado durante toda la iteración, y no solo durante cada paso, lo que hace inservible el patrón para lo que se diseñó. El lifetime está describiendo una propiedad de cada elemento, pero se ha atado a todo el trait: el nivel equivocado.

El patrón canónico: type Item de a

El GAT corrige el nivel. El lifetime no pertenece al trait —el trait es uno solo, sin parámetros—, sino al tipo asociado, que es quien de verdad varía con cada préstamo:

trait Presta {
    type Item<'a> where Self: 'a;              // el lifetime vive aqui, no en el trait
    fn next(&mut self) -> Option<Self::Item<'_>>;
}

La diferencia es estructural. Presta es un trait sin parámetros: un tipo lo implementa una vez, limpiamente, y quien lo recibe en un genérico escribe T: Presta sin arrastrar ningún lifetime. El 'a aparece solo donde de verdad importa —dentro de Item, y en el '_ de cada next—, ligado por el préstamo de esa llamada concreta y no antes. El lifetime dejó de ser una propiedad global del contrato para ser lo que siempre fue: un atributo del elemento producido en cada paso.

struct Fragmentador { texto: String, pos: usize }

impl Presta for Fragmentador {
    type Item<'a> = &'a str where Self: 'a;
    fn next(&mut self) -> Option<Self::Item<'_>> {
        if self.pos >= self.texto.len() { return None; }
        let resto = &self.texto[self.pos..];
        let fin = resto.find(' ').map(|i| self.pos + i).unwrap_or(self.texto.len());
        let palabra = &self.texto[self.pos..fin];   // presta del String interno
        self.pos = fin + 1;
        Some(palabra)
    }
}
💡
Elidir el lifetime del GAT con guion bajo

En Option<Self::Item<'_>>, el '_ no es pereza: es el lifetime elidido del préstamo &mut self de esta llamada. Escribirlo a mano sería fn next<'s>(&'s mut self) -> Option<Self::Item<'s>>, pero la elisión lo deduce sin ruido. El '_ en la posición del GAT le dice al compilador “instancia esta familia justo en el préstamo actual”, que es casi siempre lo que quieres.

🚫

Lifetime en el trait · el antipatrón

trait Presta<'a> ata el lifetime a la identidad del contrato: cada función que lo use arrastra el 'a, lo liga antes de tiempo y suele dejar self prestado durante toda la iteración. El parámetro describe el elemento, pero reside en el trait.

Lifetime en el asociado · el patrón

type Item<'a> pone el lifetime donde de verdad varía. El trait queda sin parámetros, los genéricos lo reciben con un escueto T: Presta, y el 'a asoma solo en cada next. Cada entidad depende de lo mínimo que la determina.

Hablar del tipo asociado desde un genérico

Surge un reto nuevo. Si escribes una función genérica sobre T: Presta y quieres exigir algo de sus elementos —que sean Debug, digamos—, ¿cómo nombras un tipo que solo existe una vez fijado un lifetime? No puedes decir T::Item: Debug, porque T::Item no es un tipo, es una familia. Necesitas afirmar la propiedad para todo lifetime posible, y eso es exactamente lo que expresan los bounds de rango superior, for<'a>:

use std::fmt::Debug;

fn imprime_todo<T>(mut it: T)
where
    T: Presta,
    for<'a> T::Item<'a>: Debug,   // para todo 'a, el elemento en 'a es Debug
{
    while let Some(x) = it.next() {
        println!("{x:?}");
    }
}

for<'a> T::Item<'a>: Debug se lee “cualquiera que sea el lifetime 'a, el tipo T::Item<'a> implementa Debug”. Es cuantificación universal sobre lifetimes, la misma que subyace a los cierres que aceptan referencias de cualquier duración. Los GATs y los bounds de rango superior son piezas del mismo mecanismo: uno declara la familia, el otro afirma propiedades sobre todos sus miembros a la vez.

Sin ese for<'a> tendrías que fijar un lifetime concreto —T::Item<'static>, digamos—, y eso excluiría de golpe a todos los elementos que prestan de self, justo los que motivan el patrón. La cuantificación universal es la única forma honesta de exigir una propiedad a una familia entera sin comprometerse con ninguno de sus miembros. Es la misma razón por la que una firma que acepta impl Fn(&str) esconde, por debajo, un for<'a> Fn(&'a str): promete funcionar para toda duración que le pasen, no para una elegida de antemano.

flowchart TB
subgraph Antipatron con lifetime en el trait
  m1[trait Presta de a] --> m2[el a contamina cada firma]
  m2 --> m3[self queda prestado toda la iteracion]
end
subgraph Patron canonico con lifetime en el asociado
  b1[trait Presta sin parametros] --> b2[type Item de a lleva el lifetime]
  b2 --> b3[cada next presta solo su paso]
  b3 --> b4[for de a acota todos los miembros de la familia]
end
style m3 fill:#f38ba8,color:#11111b
style b3 fill:#a6e3a1,color:#11111b
style b4 fill:#89b4fa,color:#11111b

La cláusula where Self: ’a, con precisión

Ya la viste asomar; ahora fíjala bien, porque es el detalle que más desconcierta. type Item<'a> where Self: 'a impone que el tipo Self viva al menos tanto como el lifetime 'a con que instancias la familia. La razón es la misma que gobierna cualquier préstamo: si Item<'a> va a contener una referencia al interior de self, esa referencia solo es válida mientras self exista, de modo que 'a no puede exceder la vida de Self. Sin la cláusula, el compilador no podría descartar un Item<'static> que sobreviviera a un Self ya destruido —una referencia colgante en potencia—, y por eso rechaza el trait.

trait Presta {
    // Sin 'where Self: 'a', el compilador teme un Item que sobreviva a Self:
    type Item<'a> where Self: 'a;
    fn primera(&self) -> Self::Item<'_>;
}

Piénsala como la traducción, al mundo de los tipos asociados, de la regla que aprendiste con los lifetimes en structs: un préstamo nunca vive más que aquello de lo que presta. Aquí “aquello” es Self, y la cláusula lo dice con dos palabras. Cuando el compilador la exija —lo hará, con un mensaje que la sugiere textualmente—, añádela sin misterio: estás confirmando que tus elementos no sobrevivirán al iterador que los presta.

ℹ️
El compilador te dicta la cláusula

No tienes que memorizar dónde va where Self: 'a. Si la omites, rustc no se limita a rechazar el código: sugiere, casi con esas palabras, “add where Self: 'a” y señala el tipo asociado exacto al que le falta. Los GATs son de las pocas funcionalidades donde el diagnóstico deletrea la corrección, precisamente porque la obligación —que Self sobreviva al préstamo— es mecánica y el compilador la deriva por ti. Acostúmbrate a leer esa sugerencia como lo que es: la confirmación de que tu diseño presta datos del interior de Self y de que el sistema de tipos ya lo ha entendido.

Un lifetime pertenece al concepto que de verdad varía con él

El viaje del lifetime, del trait al tipo asociado, encierra una lección de diseño que trasciende este caso concreto y que conviene grabar. Cuando algo en tu programa varía a lo largo de un eje —un lifetime, un tipo, una dimensión cualquiera—, la pregunta correcta no es “¿cómo hago que compile?”, sino “¿qué entidad, exactamente, depende de ese eje?”. En el antipatrón trait Presta<'a>, el lifetime se colgó del trait porque era el único sitio donde la sintaxis pre-GAT lo admitía, pero conceptualmente era una mentira: el trait no cambia con 'a; lo que cambia es el elemento que se produce en cada paso. Atar el lifetime al trait obligaba a todo el ecosistema —cada función genérica, cada bound, cada llamador— a cargar con un parámetro que no describía nada suyo, y a ligarlo antes de tiempo, con el efecto colateral de que el borrow checker prestaba self de más. El GAT permite por fin poner el lifetime donde vive: en type Item<'a>, el único lugar que de verdad depende de él. Y en cuanto está ahí, todo lo demás se simplifica: el trait recupera su pureza sin parámetros, los genéricos lo reciben con un escueto T: Presta, y cuando alguien necesita afirmar algo sobre los elementos, lo hace con for<'a>, cuantificando sobre la familia entera en vez de fijar un miembro arbitrario. Esta es la firma de un buen diseño de tipos: cada parámetro reside en la entidad más pequeña que realmente depende de él, ni una capa más arriba. Poner un lifetime en el trait cuando pertenece al elemento es el equivalente, en el sistema de tipos, de declarar una variable global cuando bastaba una local: funciona, contamina, y oscurece qué depende de qué. El patrón type Item<'a> no es solo la manera de que compile un LendingIterator; es la localización correcta de una dependencia, y esa corrección es la que hace que todo lo construido encima —incluida la ergonomía de async en traits— se sostenga sin andamios.

📝
Lo esencial del lifetime asociado

La forma canónica de un lifetime asociado es type Item<'a>, con el lifetime en el tipo asociado, no en el trait. El antipatrón previo —trait Trait<'a>— colgaba el lifetime del contrato entero, contaminaba cada firma y solía dejar self prestado durante toda la iteración; el GAT lo corrige devolviendo el trait a su forma sin parámetros y ligando el lifetime solo en cada next, mediante el '_. Para exigir propiedades de los elementos desde un genérico se usan bounds de rango superior, for<'a> T::Item<'a>: Debug, que cuantifican sobre todos los miembros de la familia. Y where Self: 'a garantiza que Self sobrevive al préstamo, la traducción a tipos asociados de que nada vive más que aquello de lo que presta.

⚔️ Coloca el lifetime donde vive
  1. Reescribe un trait Presta<'a> con lifetime en el trait como un trait Presta con type Item<'a>, y enumera qué desaparece de las firmas de las funciones que lo consumen.
  2. Implementa Presta para un struct Lineas { texto: String, pos: usize } con type Item<'a> = &'a str, entregando una línea por next.
  3. Escribe fn recolecta_longitudes<T: Presta>(it: T) -> Vec<usize> donde necesites un bound for<'a> sobre T::Item<'a>; explica por qué no basta con T::Item: algo.
  4. Elimina where Self: 'a de tu trait, lee el error del compilador y reintrodúcela; describe la referencia colgante que la cláusula previene.
  5. Contrasta fn next(&mut self) -> Option<Self::Item<'_>> con su versión explícita fn next<'s>(&'s mut self) -> Option<Self::Item<'s>> y explica qué elide el '_ y por qué ambas significan lo mismo.