GATs en la práctica: colecciones que prestan y async en traits
Los GATs dejaron de ser teoría en cuanto habilitaron dos cosas que la comunidad llevaba años pidiendo: traits genéricos sobre colecciones y su iterador prestado, y —sobre todo— async fn dentro de traits, que por debajo desazucara a un tipo asociado genérico sobre el lifetime de self. Recorrido por los casos reales y la historia de por qué los GATs fueron, durante seis años, la feature más esperada de Rust.
Un mecanismo del sistema de tipos se juzga por lo que permite escribir que antes era imposible. Los GATs superan esa prueba dos veces. La primera, con las colecciones genéricas que prestan: por fin puedes escribir una función que funcione para cualquier contenedor y su iterador de referencias, algo que el sistema de tipos rechazaba de plano. La segunda, y la que de verdad los llevó a todos los proyectos: async fn dentro de traits, la característica que Rust persiguió durante años y que, por debajo de su sintaxis limpia, no es más que un tipo asociado genérico sobre el lifetime de self. Este capítulo cierra el nivel bajando de la teoría a por qué los GATs fueron, durante seis años, la funcionalidad más esperada del lenguaje.
- Escribir un trait genérico sobre una colección y su iterador prestado con
type Iter<'a>. - Explicar cómo
async fnen traits desazucara a un tipo asociado genérico sobre el lifetime deself. - Conectar los GATs con
RPITIT, el retorno deimpl Traiten traits, estabilizado junto alasyncen traits. - Situar la historia: RFC de 2016, estabilización en Rust 1.65, y por qué la espera fue tan larga.
Colecciones genéricas que prestan
Antes de los GATs, no podías escribir un trait Coleccion que abstrajera a la vez el contenedor y su iterador de referencias, porque ese iterador presta de la colección y su tipo depende de un lifetime que el tipo asociado clásico no sabía nombrar. Con type Iter<'a>, el contrato se expresa por fin:
trait Coleccion {
type Elemento;
type Iter<'a>: Iterator<Item = &'a Self::Elemento> // iterador que presta
where
Self: 'a;
fn iter(&self) -> Self::Iter<'_>;
}
impl<T> Coleccion for Vec<T> {
type Elemento = T;
type Iter<'a> = std::slice::Iter<'a, T> where Self: 'a;
fn iter(&self) -> Self::Iter<'_> { self.as_slice().iter() }
}
type Iter<'a>: Iterator<Item = &'a Self::Elemento> es la clave: el iterador asociado está acotado para producir referencias con el mismo lifetime 'a que el préstamo de iter. Ahora una función genérica puede recorrer cualquier colección sin conocer su tipo de iterador concreto:
fn cuenta<C: Coleccion>(c: &C) -> usize {
let mut n = 0;
for _ in c.iter() { n += 1; } // funciona para Vec, para tu tipo, para cualquiera
n
}
Esto —ser genérico sobre un constructor de iteradores parametrizado por lifetime— era literalmente inexpresable antes, y es el patrón que subyace a bibliotecas de estructuras de datos que quieren ofrecer una interfaz común de préstamo.
async fn en traits: un GAT disfrazado
Aquí está el caso que llevó los GATs a todos los Cargo.toml. Desde Rust 1.75 puedes escribir async fn directamente dentro de un trait:
trait Cliente {
async fn pedir(&self, url: &str) -> String; // estabilizado en Rust 1.75
}
Se ve trivial, pero encierra un problema profundo que solo los GATs resuelven. Un async fn no devuelve su valor: devuelve un Future que, al ejecutarse, lo produce. Y ese Future toma prestado self mientras vive. Así que la desazucarización pasa primero por el retorno de impl Trait en traits —RPITIT—, y de ahí a un tipo asociado que debe ser genérico sobre el lifetime del préstamo:
// Lo que el compilador genera, conceptualmente, a partir del async fn:
trait Cliente {
fn pedir(&self, url: &str) -> impl Future<Output = String>; // paso 1: RPITIT
}
// Y ese impl Trait en retorno es, por debajo, un GAT anonimo:
trait ClienteDesazucarado {
type Pedir<'a>: Future<Output = String> where Self: 'a; // paso 2: GAT
fn pedir<'a>(&'a self, url: &'a str) -> Self::Pedir<'a>;
}
El Future devuelto presta de self, luego su tipo depende del lifetime 'a de ese préstamo, luego el tipo asociado que lo nombra ha de ser type Pedir<'a>: un GAT, con su inevitable where Self: 'a. La sintaxis async fn en traits es azúcar; la maquinaria que la hace posible es, exactamente, la del capítulo anterior. Sin GATs no habría async en traits, y sin async en traits no habría ecosistema async idiomático —ni un trait Service, ni middlewares, ni abstracciones sobre transportes—.
async fn en traits ya es estable para uso directo, pero conviene saber que el Future que genera no es automáticamente Send, lo que complica usarlo con ejecutores multihilo tras un dyn. Por eso durante la transición muchas bibliotecas siguen apoyándose en la macro async_trait o en definir el GAT a mano para exigir Send. La dirección del lenguaje es cerrar esa brecha, pero el fundamento —un tipo asociado genérico sobre el lifetime de self— es el mismo en todas las variantes.
RPITIT: la generalización
async fn es un caso particular de algo más amplio que se estabilizó a la vez: devolver impl Trait desde un método de trait, el RPITIT. Sirve para devolver iteradores, cierres o futuros sin nombrar su tipo concreto, y por debajo siempre es lo mismo —un tipo asociado, generalizado a GAT si el retorno presta de self—:
trait Fabrica {
fn numeros(&self) -> impl Iterator<Item = u32>; // no nombras el iterador concreto
}
struct Rango { hasta: u32 }
impl Fabrica for Rango {
fn numeros(&self) -> impl Iterator<Item = u32> {
(0..self.hasta) // devuelve un Range, oculto tras impl Trait
}
}
El implementador elige el tipo concreto del iterador; quien llama solo ve “algo que itera u32”. Es la ergonomía de impl Trait que ya conocías en funciones libres, extendida a los traits gracias a que por debajo hay un tipo asociado —anónimo— que el compilador sintetiza.
Iteradores que prestan
El LendingIterator con type Item<'a> reparte vistas del propio iterador: ventanas mutables solapadas, cursores de base de datos, parsers que reutilizan un búfer sin clonar en cada paso.
Colecciones genéricas
Un trait con type Iter<'a>: Iterator<Item = &'a Self::Elemento> abstrae cualquier contenedor y su iterador de referencias a la vez, algo antes inexpresable en el sistema de tipos.
async en traits
async fn en un trait desazucara a un tipo asociado genérico sobre el lifetime de self; sin GATs no existiría el ecosistema async idiomático que hoy sostiene servidores en producción.
flowchart TB a[async fn pedir en un trait] --> b[Paso 1 RPITIT devuelve impl Future] b --> c[Paso 2 el Future presta de self] c --> d[Tipo asociado generico sobre el lifetime que es un GAT] d --> e[type Pedir de a con where Self de a] g[Colecciones que prestan] --> d h[LendingIterator] --> d d --> i[La misma maquinaria sostiene los tres casos] style a fill:#89b4fa,color:#11111b style d fill:#cba6f7,color:#11111b style i fill:#a6e3a1,color:#11111b
La feature más esperada, y por qué tardó
El RFC de los tipos asociados genéricos —el RFC 1598— se abrió en 2016. La estabilización llegó en Rust 1.65, en noviembre de 2022: más de seis años, uno de los desarrollos más largos de la historia del lenguaje, con un hilo de seguimiento que se convirtió casi en leyenda. La espera no fue negligencia, sino la dificultad genuina de la empresa: un GAT es, en esencia, genericidad de orden superior —tipos que producen tipos—, y encajarla en un sistema que debe seguir infiriendo lo indecible y permaneciendo decidible obligó a resolver problemas teóricos sobre well-formedness y sobre qué bounds implica cada uso. Cuando por fin aterrizó, el anuncio la describió, con razón, como una de las funcionalidades más demandadas jamás. El motivo de tanta demanda no era el LendingIterator en sí —un caso llamativo pero de nicho—, sino lo que colgaba detrás: sin GATs, el async en traits, la pieza que faltaba para que Rust fuera un lenguaje async de primera clase, era imposible de estabilizar. Los seis años de espera por los GATs fueron, en el fondo, seis años de espera por el async idiomático.
La historia de los GATs enseña algo sobre cómo crece un lenguaje de sistemas, y merece cerrar el nivel con ella. Vistos desde fuera, los GATs parecían una esquina académica: ¿a cuánta gente le urge de verdad un iterador que preste de sí mismo? Muy poca. Y sin embargo fueron, durante media década, la funcionalidad más reclamada del lenguaje. La paradoja se resuelve al entender que las features fundacionales rara vez se piden por sí mismas: se piden por lo que hacen posible aguas abajo. El LendingIterator era la punta visible; bajo el agua estaba el hecho de que cualquier método que devuelva algo que presta de self —y un Future de un async fn es exactamente eso— necesita un tipo asociado capaz de variar con el lifetime del préstamo. Sin ese cimiento, el async en traits no podía existir, y sin async en traits, Rust no podía tener un ecosistema asíncrono idiomático: ni un trait común para servicios de red, ni middlewares componibles, ni abstracciones sobre bases de datos que devuelvan futuros. Toda esa superestructura, que hoy sostiene servidores en producción, descansa sobre type Item<'a>. Es un patrón que se repite en la evolución de Rust y de cualquier sistema de tipos maduro: las piezas que más tardan y más cuesta diseñar bien no son las vistosas de la superficie, sino las estructurales del sótano, esas que casi nadie usa directamente pero de las que cuelga todo lo demás. Los GATs son coste cero en el sentido más literal —desaparecen en la monomorfización, como todo genérico—, pero su verdadero valor no se mide en ciclos ahorrados, sino en abstracciones que pasaron de imposibles a rutinarias. Cuando escribas un async fn en un trait sin pensar dos veces en ello, recuerda que estás cobrando, sin verlo, el dividendo de seis años de trabajo sobre la genericidad de orden superior. Esa invisibilidad —que lo profundo se vuelva trivial de usar— es la marca de una buena feature de fundamento, y el nivel 18 entero ha sido la historia de cómo un discreto type Item; creció hasta sostener el futuro asíncrono del lenguaje.
Los GATs habilitan dos familias de casos reales. Primero, las colecciones genéricas que prestan: un trait con type Iter<'a>: Iterator<Item = &'a Self::Elemento> permite ser genérico sobre cualquier contenedor y su iterador de referencias, antes inexpresable. Segundo, y decisivo, async fn en traits —estable desde Rust 1.75—, que desazucara por RPITIT a un tipo asociado genérico sobre el lifetime de self, porque el Future devuelto presta de self: un GAT con su where Self: 'a. RPITIT generaliza la idea a cualquier impl Trait en retorno de método. Históricamente, el RFC es de 2016 y la estabilización de noviembre de 2022 en Rust 1.65: seis años de espera cuyo verdadero premio no era el LendingIterator, sino el async idiomático que colgaba de él.
- Define un trait
Coleccioncontype Elemento,type Iter<'a>: Iterator<Item = &'a Self::Elemento>yfn iter(&self) -> Self::Iter<'_>; impleméntalo paraVec<T>y escribe una función genérica que cuente sus elementos. - Escribe un trait con un
async fn cargar(&self) -> Stringy, en un comentario, desazucáralo a mano altype Cargar<'a>: Future<Output = String>equivalente; señala dónde aparece elwhere Self: 'a. - Explica por qué el
Futureque devuelve unasync fnde trait obliga a que el tipo asociado sea genérico sobre un lifetime, en lugar de un tipo asociado clásico. - Convierte un método
fn adaptadores(&self) -> impl Iterator<Item = i32>que useRPITITy razona qué tipo asociado anónimo sintetiza el compilador por debajo. - Investiga el hilo de seguimiento de los GATs: resume en tres frases por qué la estabilización tardó seis años y qué funcionalidad asíncrona dependía de ellos.