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

Asociados frente a genéricos: uno-a-uno o muchos-a-uno

El diseñador de un trait afronta una elección recurrente: ¿lleva el trait un parámetro genérico o un tipo asociado? La regla que decide es la cardinalidad de las implementaciones. Si un tipo debe implementar el trait una sola vez, el tipo va asociado; si debe implementarlo muchas veces con tipos distintos, va como parámetro genérico. Por qué From lleva genérico y Iterator lleva asociado, y por qué Add lleva los dos.

⏱ 18 min

Ya sabes qué es un tipo asociado. Ahora afrontas la decisión de diseño que lo acompaña, y que separa a quien copia sintaxis de quien entiende el sistema de tipos: cuando defines un trait que involucra otro tipo, ¿lo pones como parámetro genéricotrait Trait<T>— o como tipo asociadotype T;? No es cuestión de gusto ni de estilo. Hay una regla precisa, y se reduce a una sola pregunta: ¿cuántas veces debe un tipo poder implementar este trait? La respuesta a esa pregunta —una o muchas— dicta mecánicamente la elección, y explica por qué From lleva un genérico, Iterator lleva un asociado, y Add lleva ambos a la vez.

🎯 Al terminar esta lección sabrás
  • Formular la regla de la cardinalidad: uno-a-uno pide asociado, muchos-a-uno pide genérico.
  • Justificar por qué From<T> usa un parámetro genérico y Iterator usa un tipo asociado.
  • Analizar Add<Rhs = Self> como el caso mixto: entrada genérica, salida asociada.
  • Prever el efecto de cada elección sobre la inferencia y las anotaciones en el sitio de llamada.

La pregunta que lo decide todo

La elección entre parámetro genérico y tipo asociado se resuelve con una única pregunta sobre la relación entre el tipo implementador y el otro tipo en juego:

Para un tipo dado, ¿tiene sentido implementar este trait una sola vez o varias veces con tipos distintos?

  • Si la respuesta es una sola vez, el otro tipo es una función del implementador: va como tipo asociado.
  • Si la respuesta es varias veces, el otro tipo es una elección libre de quien implementa: va como parámetro genérico.

Es la misma dicotomía entrada/salida del capítulo anterior, ahora convertida en criterio de diseño. Un parámetro genérico es un eje sobre el que un tipo puede tener muchas implementaciones; un tipo asociado colapsa ese eje a un único valor. Todo lo demás —inferencia, ergonomía, capacidad de usar el trait como objeto— se deduce de esta elección.

From: muchas conversiones, luego genérico

String se construye desde un &str, desde un char, desde un Vec<u8>… Un mismo tipo destino admite muchas conversiones de origen, todas igual de legítimas. Esa multiplicidad es exactamente la señal de “parámetro genérico”:

pub trait From<T> {         // T es un parametro: eje de multiples impls
    fn from(value: T) -> Self;
}

// Un mismo destino, muchas implementaciones sobre el eje T:
impl From<&str> for String { /* ... */ }
impl From<char> for String { /* ... */ }
impl From<Box<str>> for String { /* ... */ }

Si From usara un tipo asociado —trait From { type Origen; }—, String solo podría convertirse desde un único tipo en todo el programa, una limitación absurda. El genérico es lo que permite las tres implementaciones coexistiendo. El precio, coherente con el capítulo anterior, es que a veces hay que decir cuál: u32::from(x) puede necesitar contexto o turbofish cuando varios orígenes encajan, porque el compilador no puede deducir por sí solo cuál de las muchas implementaciones quieres.

Iterator: un elemento, luego asociado

Un iterador produce elementos de un tipo. Contador produce u32; no tiene sentido que “también” sea, simultáneamente, un iterador de String. La relación es uno-a-uno, y por eso Item es asociado, como ya viste:

pub trait Iterator {
    type Item;                                  // uno por implementador
    fn next(&mut self) -> Option<Self::Item>;
}

La consecuencia práctica es la inferencia sin fricción. Como Contador determina su Item, el compilador lo deduce y lo propaga por toda la cadena. Compara el efecto de cada elección en el sitio de llamada:

fn main() {
    // Asociado: el Item se deduce solo, cero anotaciones
    let suma: u32 = Contador::nuevo(5).map(|x| x * 2).sum();

    // Generico: a veces hay que desambiguar cual implementacion
    let n = u32::from(3u8);          // ok, un solo origen plausible aqui
    let s = String::from("hola");    // el tipo del literal fija T = &str
}

Regla mnemotécnica: el genérico te obliga a hablar, el asociado habla por ti. Un eje de múltiples implementaciones exige que digas por cuál vas; un tipo determinado se deduce en silencio.

💡
Un síntoma revelador en el error del compilador

Cuando dudes, imagina escribir dos impl del trait para el mismo tipo con tipos distintos en la posición en cuestión. Si el compilador los aceptaría —como acepta From<&str> for String y From<char> for String—, ese tipo es un parámetro genérico. Si los rechazaría por implementaciones conflictivas —como rechazaría dos Iterator con Item distinto para un mismo tipo—, es un tipo asociado. La cardinalidad que el sistema de coherencia permite es la respuesta.

Add: el caso mixto

Add es el ejemplo que consolida la regla, porque contiene las dos figuras a la vez y cada una por su motivo exacto:

pub trait Add<Rhs = Self> {
    type Output;                          // salida: determinada por el par
    fn add(self, rhs: Rhs) -> Self::Output;
}

El operando derecho, Rhs, es un parámetro genérico con valor por defecto Self, porque un mismo tipo puede sumarse a varios: un instante temporal admite Instant + Duration, y una duración admite Duration + Duration. Muchos operandos derechos posibles, luego eje genérico. En cambio Output es un tipo asociado, porque una vez fijado el par ordenado (Self, Rhs), el tipo del resultado está determinado: f64 + f64 da f64 y nada más. La firma codifica con precisión quirúrgica qué es libre y qué es forzoso:

use std::ops::Add;

struct Metros(f64);
struct Pies(f64);

// Metros + Metros = Metros
impl Add for Metros {
    type Output = Metros;
    fn add(self, otro: Metros) -> Metros { Metros(self.0 + otro.0) }
}

// Metros + Pies = Metros: otro Rhs, misma Self, Output aun determinado por el par
impl Add<Pies> for Metros {
    type Output = Metros;
    fn add(self, otro: Pies) -> Metros { Metros(self.0 + otro.0 * 0.3048) }
}

Dos implementaciones de Add para Metros conviven porque difieren en el eje genérico Rhs. Pero cada una fija un único Output: no podrías dar dos resultados distintos a Metros + Pies. Entrada plural, salida singular, en un mismo trait.

🧬

Parámetro genérico · muchos-a-uno

El otro tipo es una entrada que elige quien implementa, y un mismo tipo puede firmar el trait muchas veces con tipos distintos —From<&str> y From<char> para String—. El precio: a veces hay que desambiguar cuál implementación quieres.

🎯

Tipo asociado · uno-a-uno

El otro tipo es una salida que el implementador determina, y el trait se firma una sola vez —el Item de Iterator, el Target de Deref—. La unicidad deja que la inferencia fluya sin una sola anotación.

flowchart TB
q[Un tipo implementa el trait una vez o muchas veces]
q -->|una vez y el otro tipo se deduce| a[Tipo asociado]
q -->|muchas veces con tipos distintos| g[Parametro generico]
a --> ae[Iterator Item, Deref Target, Add Output]
g --> ge[From T, Add Rhs, AsRef T]
ae --> ai[La inferencia fluye sola]
ge --> gi[A veces hay que desambiguar]
style a fill:#89b4fa,color:#11111b
style g fill:#cba6f7,color:#11111b
style ai fill:#a6e3a1,color:#11111b
style gi fill:#f9e2af,color:#11111b
La firma de un trait es un teorema sobre cuántos mundos permites

La elección entre genérico y asociado parece un tecnicismo ergonómico, pero es en realidad una declaración sobre la estructura del dominio que modelas, y equivocarla deforma toda una API. Cuando pones un tipo como parámetro genérico, estás afirmando: “para un mismo tipo pueden coexistir muchas versiones de esta capacidad, y quien la use tendrá que decir cuál”. Cuando lo pones como asociado, afirmas lo contrario: “para cada tipo hay una sola versión, canónica, y el compilador la encontrará sin ayuda”. Son dos ontologías distintas del mismo verbo. Piénsalo en Iterator: si su Item fuese genérico, un Vec<u8> podría ser, a la vez, iterador de bytes, de pares, de cualquier cosa —y entonces for x in v sería irremediablemente ambiguo, y el lenguaje entero de las cadenas de iteradores, con su inferencia mágica, se derrumbaría—. El diseño de std eligió la unicidad no por comodidad, sino porque la iteración es funcional: un iterador tiene un tipo de elemento del mismo modo que una función tiene un tipo de retorno. Y al revés, si From usara un asociado, cada tipo tendría un único origen de conversión, y toda la red de conversiones idiomáticas —la que el operador interrogación explota para unificar errores— sería imposible. La lección trasciende Rust: cada vez que diseñas una abstracción, decides implícitamente cuántas implementaciones por tipo permites, y esa decisión, tomada bien o mal, se propaga a cada sitio de llamada durante toda la vida de la API. Los buenos diseñadores de traits no eligen entre genérico y asociado por costumbre: leen la cardinalidad del dominio —¿uno o muchos?— y dejan que ella dicte la firma. La sintaxis es la conclusión; la cardinalidad es la premisa.

📝
Lo esencial de la elección

Pon el tipo como parámetro genérico cuando un mismo tipo deba implementar el trait varias veces con tipos distintos —From<T>, AsRef<T>, el Rhs de Add—; pon el tipo como asociado cuando la relación sea uno-a-uno y el tipo quede determinado por el implementador —el Item de Iterator, el Target de Deref, el Output de Add—. El genérico habilita múltiples implementaciones a costa de a veces exigir desambiguación; el asociado garantiza unicidad y deja que la inferencia fluya. Add combina ambos porque su operando derecho es plural y su resultado, singular. Ante la duda, pregúntate si dos impl con tipos distintos para el mismo tipo deberían coexistir: si sí, genérico; si no, asociado.

⚔️ Diseña la cardinalidad correcta
  1. Diseña un trait Serializa que convierta un tipo a varios formatos —Json, Xml, Binario—: decide si el formato va como parámetro genérico o asociado, y justifícalo con la regla de la cardinalidad.
  2. Diseña un trait Grafo con un tipo de nodo: argumenta por qué el nodo debe ser asociado y no genérico, y qué pasaría con la inferencia de recorridos si fuese genérico.
  3. Escribe dos impl From para un mismo struct Temperatura —desde f64 y desde un struct Celsius— y comprueba que coexisten; explica por qué eso demuestra que From necesita un parámetro genérico.
  4. Implementa Add<Duracion> for Instante con type Output = Instante, e intenta añadir un segundo impl de Add<Duracion> for Instante con Output distinto; interpreta el error a la luz de que Output es asociado.
  5. Toma el trait Iterator e imagina reescribirlo como trait Iterator<Item>: enumera tres puntos concretos del código idiomático —un for, un .collect(), un .sum()— que dejarían de inferir, y explica cada uno.