Trait em Rust: definindo comportamentos compartilhados

1. Visão geral

Em Rust, trait é a forma de descrever um comportamento que vários tipos de dados compartilham. É similar a uma interface em Java, mas com recursos adicionais: implementação padrão, trait bound para genéricos, e a escolha entre static dispatch ou dynamic dispatch.

Depois de ler este artigo, você saberá como definir trait, implementá-lo para struct, usar trait como parâmetro e valor de retorno, criar listas de objetos de tipos diferentes com dyn Trait. Você também entenderá por que o compilador às vezes rejeita seu código.

Todos os exemplos funcionam com Rust stable (edição 2021 ou superior) e não requerem bibliotecas externas.

2. Definindo e implementando um trait

Suponha que um sistema de vendas tenha dois tipos Order e Customer. Queremos que ambos “se resumam” em uma string curta.

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct Order {
    pub id: u32,
    pub total: u64, // unidade: 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());
}

Explicação:

  • pub trait Summary declara um trait com um método summarize. Aqui há apenas a assinatura, sem corpo.
  • impl Summary for Order fornece a implementação específica para Order. Cada tipo deve implementar todos os métodos que não têm corpo no trait.
  • &self significa que o método pega emprestado o objeto, sem tomar posse.

Resultado:

Pedido #42 - 350000 dong
Cliente An <an@example.com>

Nota: Para chamar order.summarize() de outro módulo, o trait Summary deve estar no escopo (use crate::Summary;). Este é um erro muito comum. O compilador relata “no method named summarize found” mesmo que você tenha implementado corretamente.

3. Implementação padrão

Trait pode fornecer um corpo de função por padrão. Qualquer tipo que implemente o trait usará a implementação padrão ou a sobrescreverá se necessário.

pub trait Summary {
    fn title(&self) -> String;

    fn summarize(&self) -> String {
        format!("[Resumo] {}", self.title())
    }
}

impl Summary for Order {
    fn title(&self) -> String {
        format!("Pedido #{}", self.id)
    }

    // Sobrescrever a implementação padrão
    fn summarize(&self) -> String {
        format!("{} - {} dong", self.title(), self.total)
    }
}

impl Summary for Customer {
    fn title(&self) -> String {
        format!("Cliente {}", self.name)
    }
    // Não sobrescreve summarize -> usa a implementação padrão
}

Ao executar main da seção 2 novamente, o resultado é:

Pedido #42 - 350000 dong
[Resumo] Cliente An

O ponto notável é que o método padrão summarize pode chamar title(), mesmo que title() não tenha corpo no trait. Este é um padrão útil. Você apenas requer que o tipo concreto forneça o mínimo (title), enquanto o trait constrói o resto sobre isso.

A partir daqui, o artigo usa esta versão do trait para os exemplos subsequentes.

4. Trait como parâmetro

4.1. Sintaxe impl Trait

A forma mais simples de escrever uma função que aceita “qualquer coisa que tenha Summary”:

fn print_summary(item: &impl Summary) {
    println!("Notificação: {}", item.summarize());
}

fn main() {
    let order = Order { id: 7, total: 120_000 };
    print_summary(&order);
}
Notificação: Pedido #7 - 120000 dong

4.2. Trait bound

&impl Summary é na verdade uma sintaxe abreviada para genéricos com trait bound:

fn print_summary<T: Summary>(item: &T) {
    println!("Notificação: {}", item.summarize());
}

Duas formas de escrever diferem quando há vários parâmetros:

// a e b podem ser dois tipos diferentes
fn compare_loose(a: &impl Summary, b: &impl Summary) { /* ... */ }

// a e b devem ser do mesmo tipo T
fn compare_strict<T: Summary>(a: &T, b: &T) { /* ... */ }

Chamar compare_strict(&order, &customer) resultará em erro de compilação porque Order e Customer não são o mesmo tipo.

4.3. Múltiplos bound e cláusula where

Quando você precisa de vários traits ao mesmo tempo, conecte-os com +. Quando a lista é longa, mude para where para melhor legibilidade:

use std::fmt::{Debug, Display};

fn log_item<T>(item: &T)
where
    T: Summary + Display + Debug,
{
    println!("{item} | {item:?} | {}", item.summarize());
}

Esta função apenas aceita tipos que implementam todos os três traits. Se faltar um trait, o compilador relata um erro no site da chamada, não em tempo de execução.

Genéricos em Rust usam monomorphization. O compilador gera uma versão de log_item para cada tipo concreto. Dessa forma, a chamada não tem custo em tempo de execução (static dispatch). Em troca, o binário pode ser maior.

5. Retornando impl Trait

Você também pode retornar “um tipo que implemente Summary” sem nomear o tipo:

fn latest_order() -> impl Summary {
    Order { id: 100, total: 999_000 }
}

fn main() {
    println!("{}", latest_order().summarize());
}
Pedido #100 - 999000 dong

Essa abordagem é particularmente útil com closures e iterators. Seu tipo real é geralmente muito longo ou impossível de escrever.

Nota: impl Trait na posição de retorno ainda é um único tipo concreto. O código a seguir não compila:

fn pick(vip: bool) -> impl Summary {
    if vip {
        Customer { name: "An".into(), email: "an@example.com".into() }
    } else {
        Order { id: 1, total: 0 } // erro: `if` and `else` have incompatible types
    }
}

Para retornar múltiplos tipos diferentes, você precisa de Box<dyn Summary> (seção 6).

6. Trait object com dyn Trait

Genéricos requerem que o tipo seja determinado em tempo de compilação. Às vezes você precisa de uma lista contendo tanto Order quanto Customer. Nesse caso, use um 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
[Resumo] Cliente Bình

Explicação:

  • dyn Summary é um tipo com tamanho desconhecido em tempo de compilação. Por isso, deve estar atrás de um ponteiro como Box, & ou Rc.
  • Cada trait object consiste em duas partes: um ponteiro para os dados e um ponteiro para a vtable. A chamada de summarize() é consultada na vtable em tempo de execução (dynamic dispatch).
  • A função pick da seção 5 pode ser corrigida alterando o tipo de retorno para Box<dyn Summary> e envolvendo cada branch em Box::new(...).

Comparação rápida:

Critério Generics / impl Trait dyn Trait
Dispatch Estático, pode ser inlined Dinâmico via vtable
Lista de múltiplos tipos Não Sim
Tamanho do binário Maior Menor

Nota: Nem todo trait pode ser usado com dyn. O trait deve ser dyn-compatible (anteriormente chamado de object safe). Um trait não é dyn-compatible se tem um método genérico, por exemplo fn send<T>(&self, t: T). O trait também perde essa propriedade se tem um método que retorna Self sem where Self: Sized. A razão é que a vtable não pode conter infinitas versões de um método genérico, e o compilador não sabe qual é o Self real.

7. Derive e traits padrão

A biblioteca padrão tem muitos traits familiares. Alguns traits são gerados automaticamente com #[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, "Pedido#{}", 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());
}
Pedido#5
Order { id: 5, total: 10000 }
true
7

Não implementamos ToString mas ainda podemos chamar a.to_string(). Isso é graças à blanket implementation na biblioteca padrão:

impl<T: Display + ?Sized> ToString for T { /* ... */ }

Isso significa que qualquer tipo com Display automaticamente tem ToString. Você também pode escrever blanket impl para seu próprio trait, por exemplo impl<T: Display> Summary for T. Porém, tenha cuidado, pois isso conflitará com qualquer outra implementação individual.

8. Orphan rule e padrão newtype

Rust tem a regra orphan rule. Você só pode escrever impl Trait for Type se o trait ou o tipo for definido em sua crate. Esta regra impede que dois crates implementem um trait para um tipo de duas formas diferentes.

O exemplo a seguir é rejeitado porque tanto Display quanto Vec pertencem à biblioteca padrão:

// erro E0117: apenas traits definidos na crate atual podem ser implementados para tipos definidos fora da crate
impl std::fmt::Display for Vec<Order> { /* ... */ }

A solução é o padrão newtype. Você envolve o tipo externo em um struct seu:

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, "Carrinho: {} 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}");
}
Carrinho: 2 pedidos, total 350000 dong

Cart é um tipo local, então é permitido implementar Display. Newtype não tem custo em tempo de execução: Cart tem o mesmo layout que Vec<Order>.

9. Escrevendo testes para trait

Testes em Rust residem no mesmo arquivo, em um módulo com #[cfg(test)]. Testamos tanto a implementação sobrescrita quanto a padrão, bem como o caso de chamada via 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(), "[Resumo] 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", "[Resumo] Cliente Bình"]);
    }
}

Para executar os testes:

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

O terceiro teste é importante. Ele confirma que ao passar pela vtable, cada elemento ainda chama a implementação correta de seu tipo real.

10. Conclusão

Traits são a base para compartilhar comportamento em Rust. Os pontos principais:

  • Use trait para declarar comportamento e impl Trait for Type para implementar. Métodos padrão ajudam a reduzir código duplicado.
  • Use impl Trait ou trait bound (T: Trait, where) quando o tipo é conhecido em tempo de compilação. Isso fornece static dispatch, sem custo em tempo de execução.
  • Use Box<dyn Trait> quando precisa conter ou retornar múltiplos tipos diferentes. Lembre-se do requisito dyn-compatible.
  • Lembre-se de trazer o trait ao escopo com use antes de chamar seus métodos.
  • Se encontrar orphan rule, use o padrão newtype.

Quando não criar um trait? Se o comportamento tem apenas uma implementação única e não precisa ser substituído em testes, um método regular em struct é suficiente. Trait só deve ser adicionado quando realmente há múltiplos tipos compartilhando um comportamento, ou quando você precisa abstrair para desacoplar partes do seu sistema.

rusttraitgenericspolymorphism