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

Tipos asociados: cuando el tipo de salida pertenece al trait

Un trait no solo declara métodos: también puede declarar tipos con type Item. El tipo asociado es un hueco que cada implementación rellena una única vez, y esa unicidad —una dependencia funcional del tipo implementador hacia su tipo de salida— es lo que hace que las cadenas de iteradores se infieran solas. Por qué el Item de Iterator es asociado y no un genérico.

⏱ 18 min

Un trait, hasta ahora, era una lista de firmas de método: un contrato sobre comportamiento. Pero un contrato puede pactar más que verbos. Puede pactar sustantivos: tipos que el implementador deberá aportar junto con los métodos. Eso es un tipo asociado, y el ejemplo que lo hace inolvidable es Iterator, cuyo type Item nombra qué produce cada next. La pregunta que abre el nivel es engañosamente simple: ¿por qué next devuelve Option<Self::Item> y no Option<T> para un T genérico cualquiera? La respuesta —que el tipo de los elementos es una función del iterador, no una elección libre de quien llama— es la semilla de todo lo que viene después.

🎯 Al terminar esta lección sabrás
  • Declarar un tipo asociado con type Item; dentro de la definición de un trait.
  • Resolverlo en el impl con type Item = ...; y referirlo mediante Self::Item.
  • Entender la dependencia funcional: cada impl fija un único tipo de salida.
  • Leer las firmas de Iterator, Add y Deref en clave de tipos asociados.

Un trait también declara tipos

Junto a las firmas de método, un trait puede declarar un tipo pendiente. La sintaxis es una línea type sin valor: un hueco, exactamente como una firma de método sin cuerpo es un hueco de comportamiento.

trait Contenedor {
    type Elemento;                                   // hueco de tipo, sin resolver
    fn primero(&self) -> Option<&Self::Elemento>;
    fn longitud(&self) -> usize;
}

struct Pila<T> { datos: Vec<T> }

impl<T> Contenedor for Pila<T> {
    type Elemento = T;                               // se rellena aqui, una sola vez
    fn primero(&self) -> Option<&Self::Elemento> { self.datos.first() }
    fn longitud(&self) -> usize { self.datos.len() }
}

Dentro del trait, Self::Elemento es un nombre abstracto: “el tipo de elemento, sea cual sea, que el implementador decida”. Dentro del impl, type Elemento = T; lo aterriza en un tipo concreto. Y a partir de ahí Self::Elemento y T son sinónimos para ese impl. El tipo asociado es, en esencia, una variable de salida del contrato: no la elige quien usa el trait, la fija quien lo implementa.

Iterator, el ejemplo canónico

Toda la biblioteca de iteradores descansa sobre un tipo asociado. La definición, despojada de sus decenas de métodos por defecto, cabe en tres líneas:

pub trait Iterator {
    type Item;                                 // que produce este iterador
    fn next(&mut self) -> Option<Self::Item>;  // el unico metodo obligatorio
}

Un iterador es cualquier tipo que sepa entregar su siguiente elemento; Item nombra de qué son esos elementos. Al implementarlo, lo fijas:

struct Contador { actual: u32, hasta: u32 }

impl Iterator for Contador {
    type Item = u32;                           // este iterador produce u32
    fn next(&mut self) -> Option<u32> {        // Option<Self::Item>, ya concreto
        if self.actual < self.hasta {
            self.actual += 1;
            Some(self.actual)
        } else {
            None
        }
    }
}

Con solo eso, Contador hereda gratis map, filter, sum, collect y el resto: todos los métodos por defecto de Iterator están escritos en términos de Self::Item, así que se especializan a u32 en cuanto fijas el tipo. Fijar un tipo asociado desbloquea todo un ecosistema de comportamiento derivado.

ℹ️
Self::Item frente a la forma totalmente cualificada

Dentro de un impl o de un genérico basta con Self::Item. Cuando el Self es ambiguo —un tipo que implementa varios traits con un Item cada uno— existe la forma explícita <Contador as Iterator>::Item, que nombra sin lugar a dudas “el Item que Contador tiene en tanto que Iterator”. Es la misma desambiguación que usas para métodos con <T as Trait>::metodo, aplicada a tipos.

Por qué asociado y no genérico

Imagina la alternativa: que Iterator llevara el elemento como parámetro genérico, trait Iterator<Item>. Nada impediría entonces que un mismo tipo lo implementara muchas veces, una por cada Item:

// Hipotetico y problematico: Item como parametro generico
// impl Iterator<u32> for Contador { ... }
// impl Iterator<String> for Contador { ... }   // el compilador lo permitiria

Y ahí muere la inferencia. Si Contador fuese iterador de u32 y de String, entonces contador.next() sería ambiguo —¿qué Option devuelve?—, contador.map(...) no sabría el tipo de entrada del cierre, y contador.collect() no podría deducir a qué colección va. Tendrías que anotar con turbofish en cada eslabón de cada cadena. El tipo asociado prohíbe esa multiplicidad de raíz: un tipo implementa Iterator como mucho una vez, y por tanto su Item queda determinado sin ambigüedad. Esa unicidad es lo que permite escribir v.iter().map(|x| x + 1).sum() sin una sola anotación.

Cuando un genérico necesita hablar del tipo asociado, no lo pasa: lo restringe con la sintaxis Trait<Tipo = ...>.

fn total<I>(it: I) -> u32
where
    I: Iterator<Item = u32>,   // no se pasa Item: se exige que sea u32
{
    let mut acc = 0;
    for x in it { acc += x; }
    acc
}

Iterator<Item = u32> no es “iterador parametrizado por u32”, sino “iterador cuyo Item es u32”. La igualdad liga una salida ya determinada, no inyecta una entrada. Y desde Rust 1.79 puedes incluso acotar el tipo asociado sin nombrarlo, con associated type bounds: impl Iterator<Item: Into<u64>> acepta cualquier iterador cuyos elementos se conviertan a u64, sin introducir un parámetro extra.

flowchart TB
subgraph Cada tipo determina un unico Item
  c1[Contador] --> i1[Item es u32]
  c2[Lineas] --> i2[Item es String]
  c3[Bytes] --> i3[Item es u8]
end
i1 --> nota[Cada tipo fija su Item sin ambiguedad]
i2 --> nota
i3 --> nota
nota --> infer[La inferencia fluye por la cadena sin anotar]
style i1 fill:#89b4fa,color:#11111b
style nota fill:#cba6f7,color:#11111b
style infer fill:#a6e3a1,color:#11111b

El patrón está por todas partes

En cuanto reconoces la forma, la ves en media biblioteca estándar. Add declara type Output porque el resultado de sumar dos tipos está determinado por ellos. Deref declara type Target porque un tipo se desreferencia a exactamente un destino. IntoIterator declara dos, encadenados entre sí:

pub trait Add<Rhs = Self> {
    type Output;                          // el tipo del resultado
    fn add(self, rhs: Rhs) -> Self::Output;
}

pub trait Deref {
    type Target: ?Sized;                  // a que apunta la desreferencia
    fn deref(&self) -> &Self::Target;
}

pub trait IntoIterator {
    type Item;
    type IntoIter: Iterator<Item = Self::Item>;  // un asociado acota a otro
    fn into_iter(self) -> Self::IntoIter;
}

Fíjate en IntoIterator: su type IntoIter no solo es asociado, sino que está acotado por otro asociado, Iterator<Item = Self::Item>, garantizando que el iterador producido entrega elementos del mismo tipo prometido. Los tipos asociados se refieren unos a otros y tejen contratos donde los tipos, no solo los métodos, quedan atados entre sí.

Un tipo asociado convierte un trait en una función entre tipos

Detente en la diferencia profunda entre un parámetro genérico y un tipo asociado, porque separa dos ideas que es fácil confundir. Un parámetro genérico es una entrada: quien usa el trait la elige, y por eso un tipo puede implementar From<u8>, From<u16> y From<String> a la vez —tres entradas, tres implementaciones—. Un tipo asociado es una salida: lo elige quien implementa, y por eso queda unívocamente determinado por el tipo implementador. En el lenguaje de las matemáticas, un trait con tipo asociado no es una relación cualquiera entre tipos, sino una función: a cada tipo que lo implementa le asigna exactamente un Item, un Output, un Target. Esa es precisamente la propiedad —la dependencia funcional— que hace posible la inferencia. El compilador, al ver Contador, puede calcular su Item sin ambigüedad, igual que evalúas una función en un punto; y con ese tipo ya resuelto, deja fluir la deducción por diez eslabones de una cadena de iteradores sin que anotes ni uno. Los lenguajes funcionales conocen esta construcción como type families o dependencias funcionales entre clases de tipos, y son notoriamente difíciles de añadir a un sistema de tipos sin romper la inferencia. Rust las horneó desde el principio, con una sintaxis tan discreta —type Item;— que es fácil pasarlas por alto. Pero cada vez que una cadena de map y filter se compila sin una sola anotación, estás cobrando el dividendo de esta decisión: el tipo de salida no lo adivina el compilador entre varias opciones, lo deduce de una función que tú definiste al escribir type Item = u32. Interioriza el tipo asociado como una función del tipo a su salida, y el resto del nivel —genéricos frente a asociados, y luego los GATs— será desarrollar esa única idea.

📝
Lo esencial de los tipos asociados

Un trait puede declarar tipos pendientes con type Item;, huecos que el impl rellena una sola vez con type Item = ...; y que dentro del trait se nombran Self::Item. Como cada tipo implementa el trait a lo sumo una vez, el tipo asociado queda determinado sin ambigüedad —una dependencia funcional del implementador hacia su salida—, y de ahí que las cadenas de iteradores se infieran sin anotar. Para exigir un valor concreto desde un genérico se usa la igualdad Iterator<Item = u32>, que liga una salida, no inyecta una entrada. La forma reaparece en Add con Output, en Deref con Target y en IntoIterator, donde un asociado acota a otro.

⚔️ Nombra la salida de un contrato
  1. Define un trait Coleccion con un tipo asociado Elemento y un método obtener(&self, i: usize) -> Option<&Self::Elemento>; impleméntalo para Vec<T> fijando type Elemento = T.
  2. Escribe una función genérica que reciba algo Iterator<Item = i32> y devuelva su máximo; comprueba que no necesitas anotar el tipo de los elementos en el cuerpo.
  3. Implementa Iterator para un struct Fibonacci con type Item = u64, y encadénale .take(10).collect::<Vec<_>>() sin anotar el Item en la cadena.
  4. Usa la forma totalmente cualificada <Fibonacci as Iterator>::Item en una anotación y explica cuándo esa forma es imprescindible frente a Self::Item.
  5. Argumenta, con el ejemplo de contador.next(), por qué convertir Item en un parámetro genérico Iterator<Item> destruiría la inferencia en las cadenas de iteradores.