Aprende Rust → Lección 8
Esta lección salda varias deudas. En la lección 4 quedó dicho que un enum es un conjunto cerrado y que para dejarlo abierto hacían falta los traits. En la lección 3 aparecieron From y TryFrom sin explicar qué eran. Y los iteradores de la lección 6 funcionan enteros sobre un trait. Un trait es un contrato: una lista de métodos que un tipo promete tener. Los genéricos son la otra mitad, escribir código una sola vez que sirva para cualquier tipo que firme ese contrato.
Un trait es un contrato, no un padre
La diferencia con la herencia es de forma, no de detalle. En una jerarquía, un tipo tiene un padre y recibe de él lo que recibe. Con traits no hay parentesco: hay tipos sueltos que firman los contratos que les convienen, tantos como quieran.
Declarar un trait es listar las firmas; implementarlo es cumplirlas:
trait Resumen {
fn autor(&self) -> String;
fn contenido(&self) -> String;
}
struct Tuit {
usuario: String,
texto: String,
}
struct Articulo {
titulo: String,
firma: String,
cuerpo: String,
}
impl Resumen for Tuit {
fn autor(&self) -> String {
format!("@{}", self.usuario)
}
fn contenido(&self) -> String {
self.texto.clone()
}
}
impl Resumen for Articulo {
fn autor(&self) -> String {
self.firma.clone()
}
fn contenido(&self) -> String {
format!("{}: {}", self.titulo, self.cuerpo)
}
}
Tuit y Articulo no tienen nada que ver entre sí y no comparten ni un campo. Lo único que comparten es que ambos saben decir quién los firma y qué dicen, que es justo lo que el contrato pide.
Métodos con cuerpo por defecto
Un trait puede traer implementaciones hechas, que cada tipo puede aceptar tal cual o reemplazar. Esto es lo que hace que un trait con muchos métodos siga siendo cómodo de implementar:
trait Resumen {
fn autor(&self) -> String;
fn contenido(&self) -> String;
fn resumir(&self) -> String {
let c = self.contenido();
let corto: String = c.chars().take(30).collect();
format!("{} — {corto}…", self.autor())
}
}
struct Nota {
texto: String,
}
impl Resumen for Nota {
fn autor(&self) -> String {
String::from("anónimo")
}
fn contenido(&self) -> String {
self.texto.clone()
}
}
fn main() {
let n = Nota {
texto: String::from("Los traits describen capacidades, no identidades"),
};
println!("{}", n.resumir());
}
Nota implementa dos métodos y obtiene el tercero gratis. Y fíjate en que el método por defecto llama a self.contenido() sin saber cómo está implementado: el contrato garantiza que existirá. Así funciona Iterator, el de la lección 6: tú escribes next() y recibes map, filter, sum y decenas más, todos construidos sobre esa única función.
Genéricos: escribirlo una vez
Un genérico es un hueco para un tipo que se rellena al usar la función. Pero Rust no acepta un hueco vacío: hay que decir qué debe saber hacer ese tipo, y eso se expresa con un límite.
fn mayor<T: PartialOrd>(lista: &[T]) -> &T {
let mut may = &lista[0];
for x in lista {
if x > may {
may = x;
}
}
may
}
fn main() {
let numeros = vec![34, 50, 25, 100, 65];
let letras = vec!['y', 'm', 'a', 'q'];
println!("mayor número: {}", mayor(&numeros));
println!("mayor letra: {}", mayor(&letras));
}
El T: PartialOrd dice «cualquier tipo, siempre que se pueda comparar con mayor que». Sin ese límite el código no compilaría, porque dentro de la función se usa > y el compilador no puede saber si un tipo cualquiera lo admite.
Tres formas de escribir lo mismo
Los límites se pueden poner en tres sitios, y las tres son habituales. Cuál usar es cuestión de legibilidad:
use std::fmt::Display;
fn uno<T: Display>(x: T) {
println!("{x}");
}
fn dos(x: impl Display) {
println!("{x}");
}
fn tres<T>(x: T)
where
T: Display,
{
println!("{x}");
}
fn main() {
uno(1);
dos("dos");
tres(3.0);
}
La primera es la clásica. La segunda, con impl Trait en el parámetro, es la más corta y se usa cuando el tipo solo aparece una vez. La tercera, con where, es la que se impone en cuanto hay dos o tres límites, porque saca el ruido de la línea de la firma y la deja legible.
Estático o dinámico: la decisión que importa
Hay dos maneras de usar un trait para aceptar varios tipos, y no son intercambiables. Entender la diferencia es lo que separa escribir Rust de pelearse con él.
dyn aplaza la decisión hasta la ejecución, a cambio de una indirección.El motivo por el que hacen falta las dos se ve con un caso concreto. Una función genérica con un límite no sirve para guardar tipos distintos juntos, porque al compilar T se fija en uno solo. Para una colección mixta hay que usar dyn:
trait Figura {
fn area(&self) -> f64;
fn nombre(&self) -> &str;
}
struct Circulo { radio: f64 }
struct Cuadrado { lado: f64 }
impl Figura for Circulo {
fn area(&self) -> f64 { std::f64::consts::PI * self.radio * self.radio }
fn nombre(&self) -> &str { "círculo" }
}
impl Figura for Cuadrado {
fn area(&self) -> f64 { self.lado * self.lado }
fn nombre(&self) -> &str { "cuadrado" }
}
fn describir(f: &impl Figura) {
println!("{} de área {:.2}", f.nombre(), f.area());
}
fn main() {
describir(&Circulo { radio: 1.0 });
describir(&Cuadrado { lado: 2.0 });
let figuras: Vec<Box<dyn Figura>> = vec![
Box::new(Circulo { radio: 1.0 }),
Box::new(Cuadrado { lado: 2.0 }),
Box::new(Circulo { radio: 0.5 }),
];
let total: f64 = figuras.iter().map(|f| f.area()).sum();
println!("{} figuras, área total {:.2}", figuras.len(), total);
}
El Vec<Box<dyn Figura>> es la respuesta a la deuda que dejó la lección 4. Un enum te habría obligado a enumerar de antemano todas las figuras posibles; con un trait, cualquiera puede escribir su propio tipo, implementar Figura y meterlo en ese vector sin tocar tu código. Esa es exactamente la diferencia entre un conjunto cerrado y uno abierto.
Box. Un Circulo y un Cuadrado ocupan distinto número de bytes, y los elementos de un Vec tienen que medir todos lo mismo. Box resuelve el problema guardando el valor en el montón y dejando en el vector solo un puntero, que sí mide siempre igual. Cuando veas el error doesn’t have a size known at compile-time, casi siempre falta un Box o un &.Los traits que ya venías usando
Buena parte de lo que parecía sintaxis del lenguaje en las lecciones anteriores eran traits de la biblioteca estándar:
| Trait | Qué habilita | Dónde apareció |
|---|---|---|
Display |
Imprimir con {} |
Lección 7, al dar mensaje a un error propio |
Debug |
Imprimir con {:?} |
Lección 4, con derive |
From, Into |
Convertir sin que falle | Lección 3, entre tipos numéricos |
TryFrom |
Convertir pudiendo fallar | Lección 3, con Result |
Iterator |
map, filter y todo lo demás |
Lección 6 |
PartialOrd, PartialEq |
Comparar con < y con == |
Lección 4, con derive |
Default |
Tipo::default() |
Lección 4 |
Error |
Que un tipo sirva como error | Lección 7 |
Implementar uno de ellos para un tipo propio le da acceso a toda la maquinaria que ya existe. From es el ejemplo más rentable, porque implementarlo regala into() y hace que el operador ? convierta errores por su cuenta:
struct Celsius(f64);
struct Fahrenheit(f64);
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self {
Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
}
}
fn main() {
let f = Fahrenheit::from(Celsius(100.0));
println!("{:.1} °F", f.0);
let otra: Fahrenheit = Celsius(0.0).into();
println!("{:.1} °F", otra.0);
}
Solo se escribió From, y sin embargo into() funciona en la última línea. Es un regalo de la biblioteca estándar: existe una implementación general que dice que todo lo que se puede convertir con From se puede convertir también con into, en el otro sentido.
La regla del huérfano
Hay un límite que conviene conocer antes de chocar con él: solo puedes implementar un trait para un tipo si al menos uno de los dos es tuyo. Tu trait para un tipo ajeno, sí. Un trait ajeno para tu tipo, sí. Un trait ajeno para un tipo ajeno, no.
La razón es evitar que dos bibliotecas distintas den dos implementaciones del mismo trait para el mismo tipo, dejando al compilador sin forma de elegir. La solución habitual cuando necesitas hacerlo es envolver el tipo ajeno en un struct tupla propio, exactamente la técnica de la lección 4:
use std::fmt;
struct Lista(Vec<String>);
impl fmt::Display for Lista {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let l = Lista(vec![
String::from("rojo"),
String::from("verde"),
]);
println!("{l}");
}
Display y Vec son los dos de la biblioteca estándar, así que impl Display for Vec<String> estaría prohibido. Envolviéndolo en Lista, que sí es un tipo propio, la implementación pasa a ser legítima.
En resumen
| Lo que quieres hacer | Qué usar |
|---|---|
| Describir una capacidad | trait Nombre { … } |
| Que un tipo la cumpla | impl Nombre for Tipo |
| Dar un método hecho a todos | un cuerpo por defecto dentro del trait |
| Una función para cualquier tipo capaz | fn f<T: Trait>(x: T) |
| Lo mismo, más corto | fn f(x: impl Trait) |
| Varios límites sin ensuciar la firma | una cláusula where |
| Máxima velocidad, un tipo por llamada | genéricos: despacho estático |
| Guardar tipos distintos juntos | Box<dyn Trait> |
| Un conjunto de casos cerrado | un enum (lección 4) |
| Un conjunto que otros amplíen | un trait |
| Implementar un trait ajeno en un tipo ajeno | envolverlo en un struct tupla propio |
La idea que conviene llevarse es que en Rust los tipos no se organizan en familias, se describen por lo que saben hacer. Un trait no dice qué es un tipo, sino qué se le puede pedir, y esa diferencia es la que permite que una función escrita hoy funcione con tipos que todavía no existen.
Para verlo explicado

«47.- Curso Rust. Explicación Práctica de Traits, Genéricos y Trait Bounds», del canal Jesús Conde (11:20). Material de otro canal que recomendamos como complemento, no una producción de decodigo.com. El reproductor solo se carga al pulsar, así que YouTube no recibe nada tuyo hasta entonces. También puedes verlo en YouTube.