Bounds múltiples, where y la síntesis: traits más genéricos
Cuando una función exige varias capacidades a la vez, los bounds se combinan con `+`, y si la firma crece se ordenan con `where`. La cláusula `where`, los bounds sobre tipos asociados, y la idea que cierra el nivel: traits y genéricos son, juntos, el sistema de abstracción de Rust.
Rara vez una función genérica necesita una sola capacidad. Para imprimir y clonar un valor, T debe cumplir dos traits a la vez; para recorrer un iterador y mostrar sus elementos, las condiciones se acumulan. Rust las combina con + y, cuando la firma se vuelve un muro ilegible, las reordena con la cláusula where. Pero esta lección es también el cierre del nivel, y toca dar un paso atrás: los genéricos que has aprendido son solo una mitad. La otra son los traits. Juntos forman el sistema de abstracción de Rust —su respuesta, de coste cero, a lo que otros lenguajes resuelven con herencia—.
- Combinar varias capacidades con
T: Trait1 + Trait2. - Reescribir firmas cargadas con una cláusula
wherelegible. - Poner bounds sobre tipos asociados y sobre relaciones entre parámetros.
- Articular cómo traits y genéricos forman juntos el sistema de abstracción de Rust.
Bounds múltiples con +
Cuando el cuerpo de una función usa varias capacidades de T, las exiges todas encadenándolas con +. Se lee como un “y” lógico: T debe implementar cada uno de los traits nombrados.
use std::fmt::{Debug, Display};
fn describe<T: Display + Debug + Clone>(x: T) -> T {
println!("bonito: {x}"); // usa Display
println!("crudo: {x:?}"); // usa Debug
x.clone() // usa Clone
}
Cada operación del cuerpo se apoya en un trait distinto, y el compilador comprueba que estén todos presentes antes de aceptar una llamada. El orden de los bounds no importa: Display + Debug y Debug + Display son la misma exigencia. Y solo debes pedir lo que realmente usas: sumar un bound que el cuerpo no necesita no es más seguro, solo más restrictivo para quien llama.
where: cuando la firma se complica
Con uno o dos parámetros, la sintaxis en línea va bien. Pero cuando hay varios parámetros, cada uno con varios bounds, la cabecera se vuelve un jeroglífico:
// Legible a duras penas
fn procesa<T: Clone + Debug, U: Display + Default, R: Iterator<Item = T>>(t: T, u: U, r: R) {
// ...
}
La cláusula where separa la declaración de los parámetros de sus restricciones, y la firma respira:
fn procesa<T, U, R>(t: T, u: U, r: R)
where
T: Clone + Debug,
U: Display + Default,
R: Iterator<Item = T>,
{
// ...
}
Mismo significado, exactamente; puro reordenamiento visual. Pero where no es solo cosmética: es más expresiva. Permite escribir bounds que la sintaxis en línea no admite —sobre tipos asociados o sobre tipos que no son un parámetro directo—, como veremos ahora. La regla práctica: en línea para uno o dos bounds simples, where en cuanto la cosa crece o exige condiciones sobre tipos asociados.
Bounds sobre tipos asociados y relaciones
El verdadero poder de where aparece cuando la condición no recae sobre el parámetro en sí, sino sobre algo derivado de él. Un caso cotidiano: aceptar cualquier cosa iterable cuyos elementos sepan imprimirse. El bound no es sobre I, sino sobre I::Item, el tipo asociado del iterador:
use std::fmt::Display;
fn imprime_todo<I>(iter: I)
where
I: IntoIterator,
I::Item: Display,
{
for x in iter {
println!("- {x}");
}
}
imprime_todo(vec![1, 2, 3]);
imprime_todo(["a", "b", "c"]);
I: IntoIterator dice que I se puede recorrer; I::Item: Display dice que lo que produce al recorrerlo sabe mostrarse. Esa segunda condición, sobre un tipo asociado, es intraducible a la sintaxis en línea: solo where la expresa. Es la puerta a firmas genéricas de verdad expresivas, las que verás en el ecosistema de iteradores y en las APIs de la std.
El mismo patrón permite exigir que el iterador produzca un tipo concreto. Aquí pedimos que sus elementos sean i32 para poder sumarlos:
fn suma<I>(iter: I) -> i32
where
I: IntoIterator<Item = i32>,
{
let mut total = 0;
for x in iter {
total += x;
}
total
}
assert_eq!(suma(vec![1, 2, 3]), 6);
assert_eq!(suma(1..=4), 10);
Item = i32 es un bound de igualdad sobre el tipo asociado: no basta con que I sea iterable, debe producir exactamente i32. Es otra restricción que solo la forma where (o el equivalente IntoIterator<Item = i32>) sabe expresar.
Un mismo T puede recibir bounds desde varios sitios a la vez: los del impl<T: ...>, los del método, los de una cláusula where. El compilador los unifica: T debe cumplirlos todos para que la llamada compile. No hay conflicto entre fuentes, solo suma de exigencias. Piensa en el conjunto de bounds de un T como la conjunción de todo lo que se le pide en su camino hasta el punto de uso.
La síntesis: traits + genéricos
Demos el paso atrás. Estos dos niveles —traits y genéricos— no son temas separados: son las dos mitades de una sola idea.
- Los traits definen capacidades: “un tipo que se puede comparar”, “que se puede imprimir”, “que se puede clonar”.
- Los genéricos abstraen código sobre “cualquier tipo
T”. - El bound los une: “cualquier tipo
Tque tenga la capacidadX”.
Ese T: Trait es el átomo de la abstracción en Rust, y sustituye a la herencia. Donde un lenguaje de clases dice “B es un A” y organiza el mundo en jerarquías, Rust dice “T sabe hacer X” y compone capacidades. No preguntas de qué desciende un tipo, sino qué es capaz de hacer. Y gracias a la monomorfización del nivel anterior, toda esta abstracción es de coste cero: se resuelve en compilación y se evapora en el binario.
Herencia · qué eres
Un tipo hereda comportamiento por su lugar en un árbol de clases. Acoplamiento rígido y despacho resuelto en ejecución.
Traits y genéricos · qué sabes hacer
Un tipo cumple un bound por sus capacidades, sin jerarquía y hasta retroactivamente. Composición y coste cero.
flowchart TB trait[Trait define una capacidad] --> bound[Bound T dos puntos Trait] gen[Generico abstrae sobre todo T] --> bound bound --> idea[Para todo T que sepa hacer X] idea --> mono[Monomorfizacion resuelve en compilacion] mono --> abs[Sistema de abstraccion de coste cero] style bound fill:#cba6f7,color:#11111b style idea fill:#89b4fa,color:#11111b style abs fill:#a6e3a1,color:#11111b
Aquí está la gran síntesis, la idea que reorganiza todo lo que has visto en estos dos niveles. La programación orientada a objetos clásica abstrae mediante herencia: construyes una jerarquía de clases, los subtipos sustituyen a los supertipos, y el comportamiento se resuelve en ejecución recorriendo esa jerarquía. Funciona, pero arrastra tres pecados: acopla los tipos a un árbol decidido por adelantado, obliga a cargar con lo que la clase base imponga —el problema de la base frágil—, y paga despacho dinámico en cada llamada. Rust abstrae por el camino opuesto: polimorfismo paramétrico acotado. Parametrizas sobre cualquier tipo y lo restringes solo por las capacidades que de verdad usas. Es más preciso, porque pides exactamente los traits que el cuerpo necesita, ni uno más. Es más flexible, porque un tipo puede satisfacer un bound sin haber sido diseñado para ello —incluso retroactivamente, implementando el trait después—, sin pertenecer a ninguna jerarquía. Y es gratis, porque se monomorfiza. El cambio mental es profundo: dejas de preguntar “¿qué es este tipo?”, su lugar en un árbol, y empiezas a preguntar “¿qué sabe hacer este tipo?”, sus capacidades. Genéricos más traits es esa pregunta, formalizada y verificada por el compilador sin coste alguno en ejecución. Y no es un rincón del lenguaje: es su columna vertebral. Los iteradores, los closures, el manejo de errores, la concurrencia, async —todo lo que verás a partir de aquí— se construye sobre este único cimiento. Domínalo y el resto de Rust deja de ser una colección de temas para revelarse como variaciones de una sola melodía: para todo tipo que sepa hacer lo necesario, escribe el código una vez, y que el compilador lo especialice a la perfección.
Combinas capacidades con T: Trait1 + Trait2 y, cuando la firma crece o hay bounds sobre tipos asociados, las ordenas en una cláusula where. Detrás está la idea que cierra el nivel: los traits definen capacidades, los genéricos abstraen sobre cualquier T, y el bound los une en un contrato —para todo T que sepa hacer X— resuelto en compilación y sin coste. Es el sistema de abstracción de Rust, y su alternativa a la herencia.
- Escribe
fn muestra_par<T: Display + Clone>(a: T, b: T)que imprima ambos y devuelva un clon del primero; identifica qué operación del cuerpo justifica cada bound. - Toma la firma en línea de
procesade arriba y reescríbela conwhere; verifica que compila igual y argumenta por qué es más legible. - Escribe
fn suma_todo<I>(iter: I) -> i32 where I: IntoIterator<Item = i32>que sume los elementos, y pruébala con unVecy con un array. - Modifica
imprime_todopara exigir ademásI::Item: Cloney explica por qué ese bound recae sobre el tipo asociado y no sobreI. - En un párrafo, contrasta “herencia:
Bes unA” con “genéricos:Tsabe hacerX”, y explica qué gana Rust al elegir la segunda vía.