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 Summarydeclara um trait com um métodosummarize. Aqui há apenas a assinatura, sem corpo.impl Summary for Orderfornece a implementação específica paraOrder. Cada tipo deve implementar todos os métodos que não têm corpo no trait.&selfsignifica 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 traitSummarydeve estar no escopo (use crate::Summary;). Este é um erro muito comum. O compilador relata “no method namedsummarizefound” 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 Traitna 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 comoBox,&ouRc.- 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
pickda seção 5 pode ser corrigida alterando o tipo de retorno paraBox<dyn Summary>e envolvendo cada branch emBox::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 exemplofn send<T>(&self, t: T). O trait também perde essa propriedade se tem um método que retornaSelfsemwhere 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 é oSelfreal.
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
traitpara declarar comportamento eimpl Trait for Typepara implementar. Métodos padrão ajudam a reduzir código duplicado. - Use
impl Traitou 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
useantes 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.