Trait en Rust: definir comportamiento compartido
1. Descripción general
En Rust, trait es la forma de describir un comportamiento que comparten múltiples tipos de datos. Es parecido a las interfaces en Java, pero con capacidades adicionales: implementación por defecto, trait bound para genéricos, y la opción de elegir entre static dispatch o dynamic dispatch.
Después de leer este artículo, sabrás cómo definir un trait, implementarlo para un struct, usar trait como parámetro y valor de retorno, crear listas de objetos de diferentes tipos usando dyn Trait. También entenderás por qué el compilador a veces rechaza tu código.
Todos los ejemplos funcionan con Rust stable (edición 2021 o posterior) y no requieren librerías externas.
2. Definir e implementar un trait
Supongamos que un sistema de ventas tiene dos tipos: Order y Customer. Queremos que ambos puedan “resumirse” en una cadena corta.
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct Order {
pub id: u32,
pub total: u64, // unidad: dong
}
pub struct Customer {
pub name: String,
pub email: String,
}
impl Summary for Order {
fn summarize(&self) -> String {
format!("Pedido #{} - {} dong", self.id, self.total)
}
}
impl Summary for Customer {
fn summarize(&self) -> String {
format!("Cliente {} <{}>", self.name, self.email)
}
}
fn main() {
let order = Order { id: 42, total: 350_000 };
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
println!("{}", order.summarize());
println!("{}", customer.summarize());
}
Explicación:
pub trait Summarydeclara el trait con un métodosummarize. Aquí solo está la firma, no el cuerpo.impl Summary for Orderproporciona la implementación específica paraOrder. Cada tipo debe implementar todos los métodos sin cuerpo en el trait.&selfsignifica que el método toma prestado el objeto, no toma la propiedad.
Resultado:
Pedido #42 - 350000 dong
Cliente An <an@example.com>
Nota: Para llamar a
order.summarize()desde otro módulo, el traitSummarydebe estar en scope (use crate::Summary;). Este es un error muy común. El compilador reporta “no method namedsummarizefound” aunque ya hayas implementado correctamente.
3. Implementación por defecto
Un trait puede proporcionar el cuerpo de un método de forma predeterminada. Cualquier tipo que implemente el trait usará esa implementación por defecto, o la puede anular si lo desea.
pub trait Summary {
fn title(&self) -> String;
fn summarize(&self) -> String {
format!("[Resumen] {}", self.title())
}
}
impl Summary for Order {
fn title(&self) -> String {
format!("Pedido #{}", self.id)
}
// Anular la implementación por defecto
fn summarize(&self) -> String {
format!("{} - {} dong", self.title(), self.total)
}
}
impl Summary for Customer {
fn title(&self) -> String {
format!("Cliente {}", self.name)
}
// No anulamos summarize -> usamos la versión por defecto
}
Al ejecutar nuevamente el main de la sección 2, el resultado es:
Pedido #42 - 350000 dong
[Resumen] Cliente An
Lo interesante es que el método por defecto summarize puede llamar a title(), aunque title() no tenga cuerpo en el trait. Este es un patrón muy útil. Solo solicitas al tipo concreto que proporcione lo mínimo (title), y el trait construye el resto sobre eso.
De aquí en adelante, usamos esta versión del trait para los siguientes ejemplos.
4. Trait como parámetro
4.1. Sintaxis impl Trait
La forma más simple de escribir una función que acepte “cualquier cosa que tenga Summary”:
fn print_summary(item: &impl Summary) {
println!("Notificación: {}", item.summarize());
}
fn main() {
let order = Order { id: 7, total: 120_000 };
print_summary(&order);
}
Notificación: Pedido #7 - 120000 dong
4.2. Trait bound
&impl Summary es en realidad sintaxis abreviada para un genérico con trait bound:
fn print_summary<T: Summary>(item: &T) {
println!("Notificación: {}", item.summarize());
}
Las dos formas de escribir difieren cuando hay múltiples parámetros:
// a y b pueden ser de dos tipos diferentes
fn compare_loose(a: &impl Summary, b: &impl Summary) { /* ... */ }
// a y b deben ser del mismo tipo T
fn compare_strict<T: Summary>(a: &T, b: &T) { /* ... */ }
Llamar a compare_strict(&order, &customer) genera un error de compilación porque Order y Customer no son del mismo tipo.
4.3. Múltiples bound y cláusula where
Cuando necesitas múltiples trait a la vez, conéctalos con +. Cuando la lista es larga, cambia a where para mejor legibilidad:
use std::fmt::{Debug, Display};
fn log_item<T>(item: &T)
where
T: Summary + Display + Debug,
{
println!("{item} | {item:?} | {}", item.summarize());
}
Esta función solo acepta tipos que implementen los tres trait. Si falta uno, el compilador reporta un error justo en el lugar de la llamada, no espera hasta runtime.
Los genéricos en Rust usan monomorphization. El compilador genera una copia de log_item para cada tipo concreto. Gracias a esto, la llamada no tiene costo en runtime (static dispatch). A cambio, el binario puede ser más grande.
5. Retornar impl Trait
También puedes retornar “algún tipo que implemente Summary” sin nombrar el tipo:
fn latest_order() -> impl Summary {
Order { id: 100, total: 999_000 }
}
fn main() {
println!("{}", latest_order().summarize());
}
Pedido #100 - 999000 dong
Esta forma es especialmente útil con closures e iteradores. El tipo real de estos generalmente es muy largo o imposible de escribir.
Nota:
impl Traiten posición de retorno sigue siendo un único tipo concreto. El siguiente código no compila:fn pick(vip: bool) -> impl Summary { if vip { Customer { name: "An".into(), email: "an@example.com".into() } } else { Order { id: 1, total: 0 } // error: `if` and `else` have incompatible types } }Para retornar múltiples tipos diferentes, necesitas
Box<dyn Summary>(sección 6).
6. Trait object con dyn Trait
Los genéricos requieren que el tipo sea conocido en tiempo de compilación. A veces necesitas una lista que contenga tanto Order como Customer. Para eso usa trait object:
fn main() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 50_000 }),
Box::new(Customer {
name: "Bình".to_string(),
email: "binh@example.com".to_string(),
}),
];
for item in &items {
println!("{}", item.summarize());
}
}
Pedido #1 - 50000 dong
[Resumen] Cliente Bình
Explicación:
dyn Summaryes un tipo de tamaño desconocido en tiempo de compilación. Por eso debe estar tras un puntero comoBox,&oRc.- Cada trait object consta de dos partes: un puntero a los datos y un puntero a la vtable. La llamada a
summarize()se busca en la vtable en tiempo de ejecución (dynamic dispatch). - La función
pickde la sección 5 se puede arreglar cambiando el tipo de retorno aBox<dyn Summary>y envolviendo cada rama enBox::new(...).
Comparación rápida:
| Criterio | Genéricos / impl Trait |
dyn Trait |
|---|---|---|
| Dispatch | Estático, puede inlinearse | Dinámico vía vtable |
| Lista de múltiples tipos | No | Sí |
| Tamaño del binario | Más grande | Más pequeño |
Nota: No todos los trait funcionan con
dyn. Un trait debe ser dyn-compatible (anteriormente llamado object safe). Un trait no es dyn-compatible si tiene un método genérico, por ejemplofn send<T>(&self, t: T). También pierde esta propiedad si tiene un método que retornaSelfsin la cláusulawhere Self: Sized. La razón es que la vtable no puede contener infinitas versiones de un método genérico, y el compilador no sabe cuál es realmenteSelf.
7. Derive y trait estándar
La librería estándar tiene muchos trait conocidos. Algunos se generan automáticamente con #[derive]:
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct Order {
pub id: u32,
pub total: u64,
}
impl fmt::Display for Order {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Order#{}", self.id)
}
}
fn main() {
let a = Order { id: 5, total: 10_000 };
let b = a.clone();
println!("{a}"); // Display
println!("{a:?}"); // Debug
println!("{}", a == b); // PartialEq
println!("{}", a.to_string().len());
}
Order#5
Order { id: 5, total: 10000 }
true
7
No hemos implementado ToString pero podemos llamar a a.to_string(). Eso se debe a la implementación genérica en la librería estándar:
impl<T: Display + ?Sized> ToString for T { /* ... */ }
Lo que significa que todo tipo que implemente Display automáticamente tiene ToString. También puedes escribir implementaciones genéricas para tu propio trait, por ejemplo impl<T: Display> Summary for T. Sin embargo, ten cuidado, porque entra en conflicto con todas las demás implementaciones específicas.
8. Orphan rule y patrón newtype
Rust tiene la regla orphan rule. Solo puedes escribir impl Trait for Type si el trait o el tipo se define en tu crate. Esta regla evita que dos crate implementen el mismo trait para un tipo de dos formas diferentes.
El siguiente ejemplo se rechaza porque tanto Display como Vec pertenecen a la librería estándar:
// error E0117: only traits defined in the current crate can be implemented for types defined outside of the crate
impl std::fmt::Display for Vec<Order> { /* ... */ }
La solución es el patrón newtype. Envuelves el tipo externo en un struct propio:
pub struct Cart(pub Vec<Order>);
impl fmt::Display for Cart {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let total: u64 = self.0.iter().map(|o| o.total).sum();
write!(f, "Carrito: {} pedidos, total {} dong", self.0.len(), total)
}
}
fn main() {
let cart = Cart(vec![
Order { id: 1, total: 100_000 },
Order { id: 2, total: 250_000 },
]);
println!("{cart}");
}
Carrito: 2 pedidos, total 350000 dong
Cart es un tipo local, así que se permite implementar Display. El newtype no tiene costo en runtime: Cart tiene el mismo layout que Vec<Order>.
9. Escribir tests para trait
Los tests en Rust van en el mismo archivo, en un módulo con #[cfg(test)]. Verificamos tanto la implementación anulada como la predeterminada, y también el caso de llamar a través de un trait object:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn order_overrides_default_summary() {
let order = Order { id: 42, total: 350_000 };
assert_eq!(order.summarize(), "Pedido #42 - 350000 dong");
}
#[test]
fn customer_uses_default_summary() {
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
assert_eq!(customer.summarize(), "[Resumen] Cliente An");
}
#[test]
fn trait_objects_dispatch_to_correct_impl() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 0 }),
Box::new(Customer { name: "Bình".into(), email: "b@x.vn".into() }),
];
let result: Vec<String> = items.iter().map(|i| i.summarize()).collect();
assert_eq!(result, vec!["Pedido #1 - 0 dong", "[Resumen] Cliente Bình"]);
}
}
Ejecutar los tests:
cargo test
running 3 tests
test tests::customer_uses_default_summary ... ok
test tests::order_overrides_default_summary ... ok
test tests::trait_objects_dispatch_to_correct_impl ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
El tercer test es importante. Confirma que cuando se pasa por la vtable, cada elemento sigue llamando a la implementación correcta para su tipo real.
10. Conclusión
El trait es el fundamento de compartir comportamiento en Rust. Los puntos clave:
- Usa
traitpara declarar el comportamiento eimpl Trait for Typepara implementarlo. El método por defecto ayuda a reducir código repetido. - Usa
impl Traito trait bound (T: Trait,where) cuando el tipo ya es conocido en tiempo de compilación. Esto da static dispatch, sin costo en runtime. - Usa
Box<dyn Trait>cuando necesites contener o retornar múltiples tipos diferentes. Recuerda el requisito de dyn-compatible. - Recuerda importar el trait con
useantes de llamar a sus métodos. - Si encuentras orphan rule, usa el patrón newtype.
¿Cuándo no deberías crear un trait? Si el comportamiento tiene una sola implementación y no necesita ser reemplazado en los tests, un método normal en un struct es suficiente. El trait solo vale la pena cuando realmente múltiples tipos comparten un comportamiento, o cuando necesitas abstraerte para desacoplar las partes de tu sistema.