GATs en Rust: Generic Associated Types para abstracciones más potentes

Los Generic Associated Types (GATs), estabilizados en Rust 1.65, permiten que los tipos asociados en un trait lleven parámetros genéricos propios, incluyendo lifetimes. Antes de los GATs, ciertos patrones de abstracción simplemente no se podían expresar en Rust estable.

El ejemplo más conocido que motivó los GATs es el streaming iterator o lending iterator: un iterador que presta (lends) sus elementos en lugar de devolverlos por valor. Con el trait Iterator estándar, cada elemento debe ser independiente del iterador. Con GATs, el elemento puede vivir tanto como la referencia al iterador.

El problema sin GATs: LendingIterator

Imagina que quieres un iterador sobre líneas de un fichero que evite copiar cada línea. El iterador reutiliza el mismo buffer para cada línea:

// Lo que queremos hacer:
trait LendingIterator {
    type Item<'a> where Self: 'a;  // <-- GAT: Item lleva un lifetime propio
    fn next(&mut self) -> Option<Self::Item<'_>>;
}

// Sin GATs, esto no es posible: no podemos expresar
// que Item depende del lifetime de &mut self

Con el trait Iterator estándar, next(&mut self) -> Option<Self::Item> exige que Item sea independiente del iterador. No puedes devolver una referencia al interior del iterador. Con GATs, sí puedes.

GATs en acción: un LendingIterator real

use std::io::{BufRead, BufReader, Read};

// El trait con GAT
trait LendingIterator {
    type Item<'a> where Self: 'a;
    fn next(&mut self) -> Option<Self::Item<'_>>;
}

// Implementación: iterador sobre líneas de un BufReader
struct LineasReader<R: Read> {
    reader: BufReader<R>,
    buffer: String,
}

impl<R: Read> LineasReader<R> {
    pub fn new(reader: R) -> Self {
        LineasReader {
            reader: BufReader::new(reader),
            buffer: String::new(),
        }
    }
}

impl<R: Read> LendingIterator for LineasReader<R> {
    type Item<'a> = &'a str where R: 'a;

    fn next(&mut self) -> Option<&str> {
        self.buffer.clear();
        match self.reader.read_line(&mut self.buffer) {
            Ok(0) => None,  // EOF
            Ok(_) => {
                // Devolvemos una referencia al buffer interno
                // El caller la usa y luego llamará a next() de nuevo
                Some(self.buffer.trim_end_matches('n'))
            }
            Err(_) => None,
        }
    }
}

fn contar_lineas(mut iter: impl for<'a> LendingIterator<Item<'a> = &'a str>) -> usize {
    let mut count = 0;
    while iter.next().is_some() {
        count += 1;
    }
    count
}

GATs para colecciones que prestan sus elementos

Otro caso clásico es un trait para colecciones donde los elementos son referencias al interior de la colección:

trait Coleccion {
    type Elemento<'a> where Self: 'a;

    fn obtener(&self, idx: usize) -> Option<Self::Elemento<'_>>;
    fn longitud(&self) -> usize;
}

// Vec implementa el trait devolviendo referencias
impl<T> Coleccion for Vec<T> {
    type Elemento<'a> = &'a T where T: 'a;

    fn obtener(&self, idx: usize) -> Option<&T> {
        self.get(idx)
    }

    fn longitud(&self) -> usize {
        self.len()
    }
}

// Una implementación que devuelve valores calculados
struct RangoColeccion {
    desde: i32,
    hasta: i32,
}

impl Coleccion for RangoColeccion {
    type Elemento<'a> = i32;  // No es una referencia, es un valor

    fn obtener(&self, idx: usize) -> Option<i32> {
        let valor = self.desde + idx as i32;
        if valor <= self.hasta { Some(valor) } else { None }
    }

    fn longitud(&self) -> usize {
        (self.hasta - self.desde + 1).max(0) as usize
    }
}

GATs con múltiples parámetros

trait Transformable {
    type Entrada<'a> where Self: 'a;
    type Salida<'a> where Self: 'a;

    fn transformar<'a>(&'a self, entrada: Self::Entrada<'a>) -> Self::Salida<'a>;
}

struct MayusculasTransformer;

impl Transformable for MayusculasTransformer {
    type Entrada<'a> = &'a str;
    type Salida<'a> = String;  // No presta, devuelve owned

    fn transformar<'a>(&'a self, entrada: &'a str) -> String {
        entrada.to_uppercase()
    }
}

GATs y const generics combinados

trait ArrayContainer {
    type Buffer<const N: usize>;
    fn nuevo<const N: usize>() -> Self::Buffer<N>;
}

struct StackAllocator;

impl ArrayContainer for StackAllocator {
    type Buffer<const N: usize> = [u8; N];

    fn nuevo<const N: usize>() -> [u8; N] {
        [0u8; N]
    }
}

struct HeapAllocator;

impl ArrayContainer for HeapAllocator {
    type Buffer<const N: usize> = Vec<u8>;

    fn nuevo<const N: usize>() -> Vec<u8> {
        vec![0u8; N]
    }
}

Limitaciones actuales de los GATs

Los GATs en Rust 1.65+ tienen algunas limitaciones conocidas:

  • El compilador a veces requiere constraints adicionales (where Self: 'a) que deberían inferirse automáticamente. Los mensajes de error cuando faltan pueden ser confusos.
  • Implementar GATs en objetos trait (dyn Trait) no siempre funciona.
  • Algunos patrones avanzados de GATs con HRTBs (Higher-Rank Trait Bounds) tienen limitaciones.

El equipo de Rust está trabajando en mejorar la ergonomía de los GATs. Para la mayoría de casos prácticos actuales, los GATs funcionan bien y son estables.

Cuándo usar GATs

Los GATs son la herramienta correcta cuando necesitas:

  • Un trait que devuelva referencias con lifetimes ligados al receptor (&mut self).
  • Abstracciones de colección donde los elementos pueden ser referencias o valores según la implementación.
  • Traits para transformaciones que pueden preservar o cambiar los lifetimes de los datos.
  • Parsers o deserializadores que evitan copias prestando sus resultados.

Para abstracciones más simples donde los lifetimes no son el problema, los tipos asociados sin GATs o simplemente los genéricos son suficientes y producen errores de compilación más claros.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP