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 Summary declara el trait con un método summarize. Aquí solo está la firma, no el cuerpo.
  • impl Summary for Order proporciona la implementación específica para Order. Cada tipo debe implementar todos los métodos sin cuerpo en el trait.
  • &self significa 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 trait Summary debe estar en scope (use crate::Summary;). Este es un error muy común. El compilador reporta “no method named summarize found” 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 Trait en 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 Summary es un tipo de tamaño desconocido en tiempo de compilación. Por eso debe estar tras un puntero como Box, & o Rc.
  • 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 pick de la sección 5 se puede arreglar cambiando el tipo de retorno a Box<dyn Summary> y envolviendo cada rama en Box::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 ejemplo fn send<T>(&self, t: T). También pierde esta propiedad si tiene un método que retorna Self sin la cláusula where 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 realmente Self.

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 trait para declarar el comportamiento e impl Trait for Type para implementarlo. El método por defecto ayuda a reducir código repetido.
  • Usa impl Trait o 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 use antes 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.

rusttraitgenericspolymorphism