Traits avanzados en Rust
En la página de Traits aprendiste a definir un trait (rasgo) y a implementarlo. En las páginas de Genéricos y Traits comunes usaste traits como Display, From y Add. Con eso tienes suficiente para la mayor parte del código del día a día. Pero cuando leas la biblioteca estándar, o un crate popular, verás funciones de los traits que al principio parecen un poco raras: type Item, Add<Rhs = Self>, trait Pretty: Display, impl<T: Display> ... for T.
Esta página explica esas funciones una por una. Ninguna es magia. Cada una resuelve un problema pequeño y real, y en cuanto conoces el problema, la sintaxis tiene sentido. Ve con calma: puedes leer una sección, probar el código y volver más tarde.
Tipos asociados: una respuesta por tipo
Sección titulada «Tipos asociados: una respuesta por tipo»Ya conoces un tipo asociado: Iterator tiene type Item. Un tipo asociado es un tipo “de relleno” dentro de un trait. Cada implementación lo rellena una sola vez.
Aquí tienes un pequeño trait Container. Una lista de reproducción guarda Strings y una lista de puntuaciones guarda u32s, así que cada una elige su propio Item:
use std::fmt::Debug;
trait Container { type Item;
fn get(&self, index: usize) -> Option<&Self::Item>; fn size(&self) -> usize;
fn first(&self) -> Option<&Self::Item> { self.get(0) }}
struct Playlist { songs: Vec<String>,}
struct Scores { values: Vec<u32>,}
impl Container for Playlist { type Item = String;
fn get(&self, index: usize) -> Option<&String> { self.songs.get(index) } fn size(&self) -> usize { self.songs.len() }}
impl Container for Scores { type Item = u32;
fn get(&self, index: usize) -> Option<&u32> { self.values.get(index) } fn size(&self) -> usize { self.values.len() }}
// Funciona con cualquier Container cuyos elementos se puedan imprimir con {:?}fn describe<C: Container>(c: &C)where C::Item: Debug,{ println!("{} elementos, el primero es {:?}", c.size(), c.first());}
fn main() { let playlist = Playlist { songs: vec![String::from("Intro"), String::from("Outro")] }; let scores = Scores { values: vec![90, 72, 85] };
describe(&playlist); describe(&scores);}Salida:
2 elementos, el primero es Some("Intro")3 elementos, el primero es Some(90)Dentro del trait, Self::Item significa “el tipo que haya elegido esta implementación”. C::Item en describe significa lo mismo para el tipo C. (La línea where es solo un sitio más ordenado para escribir el bound; hablamos de ella al final de esta página).
¿Por qué no usar un parámetro genérico? Podrías escribir trait Container<T>. Pero entonces un mismo tipo podría implementarlo muchas veces (Container<String> y Container<u32>), y cada vez que lo usaras tendrías que decir cuál quieres. Para una lista de reproducción eso no tiene sentido: guarda canciones, y punto.
Justo por eso Iterator usa type Item. Un iterador sobre un Vec<i32> produce i32 y nada más, así que for x in v.iter() nunca necesita una anotación de tipo. Una regla práctica sencilla:
- Una respuesta natural por tipo → tipo asociado (
Iterator::Item,Add::Output). - Muchas respuestas válidas por tipo → parámetro genérico (
From<T>: unStringse puede construir a partir de un&str, unchar, unBox<str>, …).
Parámetros de tipo por defecto y operadores
Sección titulada «Parámetros de tipo por defecto y operadores»En la página de Traits comunes hiciste que + funcionara con un Point implementando Add. Así es como está definido Add de verdad en la biblioteca estándar:
trait Add<Rhs = Self> { type Output; fn add(self, rhs: Rhs) -> Self::Output;}Aquí se juntan dos funciones. Output es un tipo asociado (lo que produce el +). Y Rhs = Self es un parámetro de tipo por defecto: “el lado derecho es del mismo tipo que el izquierdo, a menos que digas otra cosa”. Cuando escribes impl Add for Point, obtienes el valor por defecto. Cuando quieres sumar un tipo distinto, escribes impl Add<OtroTipo> for Point.
use std::ops::Add;
#[derive(Debug, Clone, Copy)]struct Meters(f64);
#[derive(Debug, Clone, Copy)]struct Centimeters(f64);
// Usa el valor por defecto Rhs = Self, así que esto es Meters + Metersimpl Add for Meters { type Output = Meters; fn add(self, other: Meters) -> Meters { Meters(self.0 + other.0) }}
// Cambia el valor por defecto: Meters + Centimetersimpl Add<Centimeters> for Meters { type Output = Meters; fn add(self, other: Centimeters) -> Meters { Meters(self.0 + other.0 / 100.0) }}
fn main() { let a = Meters(2.0); let b = Meters(3.5); let c = Centimeters(50.0);
println!("{:?}", a + b); println!("{:?}", a + c);}Salida:
Meters(5.5)Meters(2.5)Fíjate en que Add usa las dos herramientas: un parámetro genérico para el lado derecho (muchas respuestas válidas: puedes sumar Meters o Centimeters) y un tipo asociado para el resultado (una respuesta para cada pareja). Los parámetros de tipo por defecto sirven sobre todo cuando diseñas un trait: mantienen corto el caso habitual sin cerrarle la puerta al caso raro.
Supertraits: “también tienes que implementar este”
Sección titulada «Supertraits: “también tienes que implementar este”»A veces tu trait solo tiene sentido si el tipo ya sabe hacer otra cosa. Un supertrait dice “para implementar mi trait, también tienes que implementar ese otro”. Se escribe con dos puntos, como un bound:
use std::fmt;
// Todo lo que sea Pretty también tiene que ser Displaytrait Pretty: fmt::Display { fn pretty(&self) -> String { format!("*** {} ***", self) }}
struct Temperature { celsius: f64,}
impl fmt::Display for Temperature { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:.1}°C", self.celsius) }}
impl Pretty for Temperature {}
fn main() { let t = Temperature { celsius: 21.456 }; println!("{}", t); println!("{}", t.pretty());}Salida:
21.5°C*** 21.5°C ***El método por defecto pretty puede usar {} con self porque el supertrait promete que Display está ahí. Sin : fmt::Display, Rust rechazaría ese format!, ya que no podría saber si el tipo se puede imprimir. impl Pretty for Temperature {} está vacío: solo dice “sí, usa el método por defecto”.
Ejemplos reales de la biblioteca estándar: Copy: Clone (todo tipo Copy tiene que ser también Clone) y Eq: PartialEq.
Blanket implementations: un impl para muchos tipos
Sección titulada «Blanket implementations: un impl para muchos tipos»Una blanket implementation (implementación general) implementa un trait para todos los tipos que cumplen una condición, de una sola vez. La sintaxis es impl<T: AlgúnBound> MiTrait for T:
use std::fmt::Display;
trait Shout { fn shout(&self) -> String;}
// Todo tipo que implemente Display recibe Shout gratisimpl<T: Display> Shout for T { fn shout(&self) -> String { format!("¡{}!", self.to_string().to_uppercase()) }}
fn main() { println!("{}", "hola".shout()); println!("{}", 42.shout()); println!("{}", 'a'.shout()); println!("{}", String::from("rust es divertido").shout());}Salida:
¡HOLA!¡42!¡A!¡RUST ES DIVERTIDO!Nunca escribimos impl Shout for i32 ni impl Shout for char. El blanket impl los cubrió a todos, incluso los tipos con Display que todavía no existen.
Has usado un blanket impl muchas veces sin saberlo. La biblioteca estándar tiene (más o menos) impl<T: Display> ToString for T. Por eso todo tipo con Display tiene un método .to_string(). El mismo truco te da Into gratis cuando implementas From.
Un límite: una vez que existe un blanket impl, no puedes añadir un segundo impl más específico para un tipo que ya cubre (por ejemplo, un impl Shout for i32 especial). Rust tendría dos respuestas, así que se niega con un error de “conflicting implementations” (implementaciones en conflicto).
La regla del huérfano y el truco del newtype
Sección titulada «La regla del huérfano y el truco del newtype»Esta regla sorprende a todo el mundo alguna vez. Intenta darle a Vec<String> un Display bonito:
use std::fmt;
impl fmt::Display for Vec<String> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[{}]", self.join(", ")) }}error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate --> src/main.rs:3:1 |3 | impl fmt::Display for Vec<String> { | ^^^^^^^^^^^^^^^^^^^^^^----------- | | | `Vec` is not defined in the current crate | = note: define and implement a trait or new type insteadEl compilador dice que solo puedes implementar traits definidos en tu crate para tipos definidos fuera de él, y te avisa de que Vec no está definido en tu crate. Es la orphan rule (regla del huérfano): puedes implementar un trait para un tipo solo si el trait o el tipo están definidos en tu crate. Display pertenece a la biblioteca estándar, y Vec también, así que ninguno de los dos es tuyo.
¿Por qué existe esta regla? Imagina que dos crates de los que dependes escribieran cada uno su propio impl Display for Vec<String>. ¿Cuál debería usar Rust? No hay una buena respuesta, así que Rust hace que esa situación sea imposible.
La solución es el patrón newtype (tipo nuevo): envuelve el tipo ajeno en una pequeña struct tuya. Ahora el tipo sí es tuyo, y puedes implementarle lo que quieras:
use std::fmt;
// Un envoltorio local alrededor de un tipo ajenostruct Shopping(Vec<String>);
impl fmt::Display for Shopping { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[{}]", self.0.join(", ")) }}
fn main() { let list = Shopping(vec![String::from("leche"), String::from("huevos"), String::from("pan")]); println!("Necesitas: {}", list); println!("Son {} cosas", list.0.len());}Salida:
Necesitas: [leche, huevos, pan]Son 3 cosasself.0 llega al Vec de dentro. Un newtype no tiene ningún costo al ejecutar: en memoria, Shopping es exactamente un Vec<String>. La desventaja es que los métodos de Vec no están directamente en Shopping, así que escribes list.0.len() (o añades los métodos que necesites). Los newtypes sirven para mucho más que la regla del huérfano; consulta Patrones de diseño.
Sintaxis totalmente calificada: cuando los nombres chocan
Sección titulada «Sintaxis totalmente calificada: cuando los nombres chocan»Dos traits pueden tener métodos con el mismo nombre, y el propio tipo también puede tener uno. ¿A cuál llama robot.greet()?
trait English { fn greet(&self) -> String; fn language() -> String;}
trait Spanish { fn greet(&self) -> String; fn language() -> String;}
struct Robot;
impl Robot { fn greet(&self) -> String { String::from("BEEP BOOP") }}
impl English for Robot { fn greet(&self) -> String { String::from("Hello!") } fn language() -> String { String::from("inglés") }}
impl Spanish for Robot { fn greet(&self) -> String { String::from("¡Hola!") } fn language() -> String { String::from("español") }}
fn main() { let r = Robot;
println!("{}", r.greet()); // gana el método propio del tipo println!("{}", English::greet(&r)); // elegir un trait por su nombre println!("{}", Spanish::greet(&r));
// Aquí no hay `self`, así que Rust necesita el tipo Y el trait: println!("{}", <Robot as English>::language()); println!("{}", <Robot as Spanish>::language());}Salida:
BEEP BOOPHello!¡Hola!inglésespañolTres niveles, del más corto al totalmente explícito:
r.greet(): se elige primero el método propio (inherente) del tipo.English::greet(&r): “la versión deEnglish”. Rust deduce el tipo a partir de&r.<Robot as English>::language(): la sintaxis totalmente calificada (fully qualified syntax). La necesitas para funciones sinself, porque no hay ningún valor que Rust pueda mirar.English::language()a secas falla conerror[E0790]: cannot call associated function on trait without specifying the corresponding impl type, es decir, Rust no puede llamar a la función asociada del trait sin saber de qué tipo es la implementación.
No lo necesitarás a menudo. Cuando te haga falta, el error del compilador suele sugerirte justo la solución.
impl Trait en argumentos y valores de retorno, y where
Sección titulada «impl Trait en argumentos y valores de retorno, y where»Puedes escribir impl Trait en dos lugares, y en cada uno significa algo un poco distinto:
- En un argumento,
fn f(x: impl Trait): quien llama a la función elige el tipo. Es una forma corta de escribir una función genéricafn f<T: Trait>(x: T). - En el retorno,
fn f() -> impl Trait: la función elige un tipo concreto y lo mantiene oculto. Quien la llama solo sabe que “es algo que implementaTrait”.
// Elige quien llama: sirve cualquier iterador de u32fn print_all(items: impl Iterator<Item = u32>) { let all: Vec<u32> = items.collect(); println!("{:?}", all);}
// Elige la función: el tipo real es un Filter<...> larguísimo que nunca escribimosfn evens_up_to(limit: u32) -> impl Iterator<Item = u32> { (0..=limit).filter(|n| n % 2 == 0)}
fn main() { print_all(vec![3, 1, 4].into_iter()); print_all(evens_up_to(10));}Salida:
[3, 1, 4][0, 2, 4, 6, 8, 10]impl Trait en el retorno es especialmente útil con closures e iteradores. El tipo real del resultado de evens_up_to incluye el tipo del closure, y los closures no tienen un nombre que puedas escribir. impl Iterator te permite saltártelo.
Cuando una función tiene varios parámetros genéricos con varios bounds cada uno, los corchetes angulares se llenan demasiado. Una cláusula where lleva los bounds después de la firma, para que la forma de la función se lea mejor:
use std::fmt::{Debug, Display};
// Abarrotado: fn report<T: Display, U: Debug + PartialOrd>(label: T, a: U, b: U) -> String// Más ordenado:fn report<T, U>(label: T, a: U, b: U) -> Stringwhere T: Display, U: Debug + PartialOrd,{ let bigger = if a > b { a } else { b }; format!("{}: el mayor es {:?}", label, bigger)}
fn main() { println!("{}", report("puntos", 7, 12)); println!("{}", report(String::from("nombres"), "ann", "bob"));}Salida:
puntos: el mayor es 12nombres: el mayor es "bob"Para el compilador, las dos formas significan exactamente lo mismo. where también es el lugar natural para los bounds sobre tipos asociados, como C::Item: Debug en el primer ejemplo de esta página.
Errores comunes
Sección titulada «Errores comunes»Implementar un trait pero olvidar su supertrait. Si quitas el bloque impl fmt::Display for Temperature del ejemplo de supertraits, obtienes:
error[E0277]: `Temperature` doesn't implement `std::fmt::Display` --> src/main.rs:13:17 |13 | impl Pretty for Temperature {} | ^^^^^^^^^^^ unsatisfied trait bound |note: required by a bound in `Pretty` --> src/main.rs:3:15 | 3 | trait Pretty: fmt::Display { | ^^^^^^^^^^^^ required by this bound in `Pretty`El compilador dice que Temperature no implementa Display, y la nota señala justo el : fmt::Display que lo exige. La solución es implementar también el supertrait (aquí, Display).
Devolver dos tipos distintos desde -> impl Trait. impl Trait oculta un solo tipo. No puede ser “a veces este, a veces aquel”:
fn numbers(evens: bool) -> impl Iterator<Item = u32> { if evens { (0..10).filter(|n| n % 2 == 0) } else { vec![1, 3, 5].into_iter() }}error[E0308]: `if` and `else` have incompatible types --> src/main.rs:5:9 |2 | / if evens {3 | | (0..10).filter(|n| n % 2 == 0) | | ------------------------------ | | | | | | | the expected closure | | expected because of this4 | | } else {5 | | vec![1, 3, 5].into_iter() | | ^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Filter<Range<{integer}>, {closure@...}>`, found `IntoIter<{integer}>`6 | | } | |_____- `if` and `else` have incompatible types | = note: expected struct `Filter<std::ops::Range<{integer}>, {closure@src/main.rs:3:24: 3:27}>` found struct `std::vec::IntoIter<{integer}>`help: you could change the return type to be a boxed trait object |1 - fn numbers(evens: bool) -> impl Iterator<Item = u32> {1 + fn numbers(evens: bool) -> Box<dyn Iterator<Item = u32>> { |help: if you change the return type to expect trait objects, box the returned expressions |3 ~ Box::new((0..10).filter(|n| n % 2 == 0))4 | } else {5 ~ Box::new(vec![1, 3, 5].into_iter()) |El error dice que las ramas if y else tienen tipos incompatibles: una devuelve un Filter y la otra un IntoIter. Cuando el tipo cambia de verdad al ejecutar, devuelve un Box<dyn Trait>, como sugiere el compilador. Consulta Trait objects.
Resumen
Sección titulada «Resumen»- Un tipo asociado (
type Item) da una respuesta por implementación. Usa un parámetro genérico cuando un tipo puede implementar el trait de muchas formas. - Los parámetros de tipo por defecto, como
Add<Rhs = Self>, mantienen corto el caso habitual;impl Add<Otro> for Tlos cambia. - Un supertrait (
trait Pretty: Display) exige otro trait y permite que los métodos por defecto lo usen. - Un blanket impl (
impl<T: Display> MiTrait for T) cubre de una vez todos los tipos que cumplen la condición: así funciona.to_string(). - La regla del huérfano te impide implementar un trait ajeno para un tipo ajeno. Envuelve el tipo en un newtype para saltártela.
- Usa la sintaxis totalmente calificada
<Tipo as Trait>::funcion()cuando los nombres chocan;impl Traiten el retorno oculta un único tipo concreto;wherehace legibles los bounds largos.
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»Comprueba lo aprendido
1. ¿Por qué Iterator usa type Item en lugar de Iterator<T>?
2. ¿Qué significa Rhs = Self en trait Add<Rhs = Self>?
3. Quieres escribir impl Display for Vec<String>, pero Rust no te deja. ¿Cuál es la solución habitual?
4. ¿Qué línea llama a la versión de English de una función sin parámetro self?
5. En fn evens() -> impl Iterator<Item = u32>, ¿quién decide el tipo concreto del iterador?
Puntuación: 0 / 5
Para leer más
Sección titulada «Para leer más»- Traits avanzados — El libro de Rust (en inglés): tipos asociados, parámetros de tipo por defecto, supertraits y el patrón newtype, con más ejemplos.
- Traits — The Rust Reference (en inglés): las reglas precisas de los traits, los supertraits y los elementos asociados.
- Orphan rules — The Rust Reference (en inglés): la definición exacta de cuándo se permite un impl.
impl Trait— The Rust Reference (en inglés): en qué se diferencianimpl Traiten un argumento y en el retorno.- Supertraits — Rust by Example (en inglés): un ejemplo corto y ejecutable de traits que se apoyan en otros traits.