Uma das decisões de design mais importantes em Rust é escolher entre generics (dispatch estático) e trait objects (dispatch dinâmico). Ambos permitem polimorfismo, mas com trade-offs fundamentais em performance, flexibilidade e ergonomia. Neste artigo, vamos explorar como cada mecanismo funciona por dentro, quando usar qual e como evitar armadilhas comuns.
O Problema: Polimorfismo em Rust
Suponha que você tem diferentes tipos que implementam o mesmo comportamento:
trait Forma {
fn area(&self) -> f64;
fn nome(&self) -> &str;
}
struct Circulo {
raio: f64,
}
struct Retangulo {
largura: f64,
altura: f64,
}
struct Triangulo {
base: f64,
altura: f64,
}
impl Forma for Circulo {
fn area(&self) -> f64 {
std::f64::consts::PI * self.raio * self.raio
}
fn nome(&self) -> &str { "Círculo" }
}
impl Forma for Retangulo {
fn area(&self) -> f64 {
self.largura * self.altura
}
fn nome(&self) -> &str { "Retângulo" }
}
impl Forma for Triangulo {
fn area(&self) -> f64 {
self.base * self.altura / 2.0
}
fn nome(&self) -> &str { "Triângulo" }
}
Como escrever uma função que aceita qualquer Forma? Existem duas abordagens.
Abordagem 1: Generics (Dispatch Estático)
Com generics, o compilador gera uma versão especializada da função para cada tipo concreto:
# trait Forma {
# fn area(&self) -> f64;
# fn nome(&self) -> &str;
# }
# struct Circulo { raio: f64 }
# struct Retangulo { largura: f64, altura: f64 }
# impl Forma for Circulo {
# fn area(&self) -> f64 { std::f64::consts::PI * self.raio * self.raio }
# fn nome(&self) -> &str { "Círculo" }
# }
# impl Forma for Retangulo {
# fn area(&self) -> f64 { self.largura * self.altura }
# fn nome(&self) -> &str { "Retângulo" }
# }
// Sintaxe com trait bound
fn imprimir_area<T: Forma>(forma: &T) {
println!("{}: área = {:.2}", forma.nome(), forma.area());
}
// Sintaxe equivalente com impl Trait
fn imprimir_area_v2(forma: &impl Forma) {
println!("{}: área = {:.2}", forma.nome(), forma.area());
}
fn main() {
let c = Circulo { raio: 5.0 };
let r = Retangulo { largura: 4.0, altura: 6.0 };
imprimir_area(&c); // Usa versão especializada para Circulo
imprimir_area(&r); // Usa versão especializada para Retangulo
}
Como Funciona: Monomorfização
O compilador gera código separado para cada tipo. Conceitualmente, o resultado é:
Código fonte: Após monomorfização:
┌─────────────────────────────┐
fn imprimir_area<T: Forma> ──> │ fn imprimir_area_Circulo() │
│ fn imprimir_area_Retangulo()│
│ fn imprimir_area_Triangulo()│
└─────────────────────────────┘
Cada versão sabe exatamente qual tipo está usando, permitindo inlining e outras otimizações. A chamada ao método é resolvida em tempo de compilação — não há custo de indireção.
Abordagem 2: Trait Objects (Dispatch Dinâmico)
Com trait objects (dyn Trait), o tipo concreto é resolvido em tempo de execução:
# trait Forma {
# fn area(&self) -> f64;
# fn nome(&self) -> &str;
# }
# struct Circulo { raio: f64 }
# struct Retangulo { largura: f64, altura: f64 }
# struct Triangulo { base: f64, altura: f64 }
# impl Forma for Circulo {
# fn area(&self) -> f64 { std::f64::consts::PI * self.raio * self.raio }
# fn nome(&self) -> &str { "Círculo" }
# }
# impl Forma for Retangulo {
# fn area(&self) -> f64 { self.largura * self.altura }
# fn nome(&self) -> &str { "Retângulo" }
# }
# impl Forma for Triangulo {
# fn area(&self) -> f64 { self.base * self.altura / 2.0 }
# fn nome(&self) -> &str { "Triângulo" }
# }
// Aceita qualquer tipo que implemente Forma
fn imprimir_area_dyn(forma: &dyn Forma) {
println!("{}: área = {:.2}", forma.nome(), forma.area());
}
// Coleção heterogênea — só é possível com dyn
fn area_total(formas: &[Box<dyn Forma>]) -> f64 {
formas.iter().map(|f| f.area()).sum()
}
fn main() {
let formas: Vec<Box<dyn Forma>> = vec![
Box::new(Circulo { raio: 5.0 }),
Box::new(Retangulo { largura: 4.0, altura: 6.0 }),
Box::new(Triangulo { base: 3.0, altura: 8.0 }),
];
for forma in &formas {
imprimir_area_dyn(forma.as_ref());
}
println!("Área total: {:.2}", area_total(&formas));
}
Como Funciona: vtable
Um trait object (&dyn Trait) é um fat pointer — dois ponteiros de 8 bytes:
&dyn Forma (fat pointer)
┌────────────┬────────────┐
│ *data │ *vtable │
│ (dados) │ (tabela) │
└─────┬──────┴─────┬──────┘
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ Circulo │ │ vtable: │
│ raio: 5.0│ │ drop: ... │
└──────────┘ │ size: 8 │
│ align: 8 │
│ area: 0x... │
│ nome: 0x... │
└──────────────┘
Cada chamada de método passa pela vtable — uma tabela de ponteiros para as implementações concretas. Isso tem um custo pequeno (uma indireção a mais) e impede inlining.
Comparação Detalhada
┌──────────────────────┬────────────────────────┬───────────────────────┐
│ Aspecto │ Generics │ Trait Objects │
├──────────────────────┼────────────────────────┼───────────────────────┤
│ Dispatch │ Estático (compilação) │ Dinâmico (execução) │
│ Performance │ Máxima (inlining) │ Leve overhead │
│ Tamanho do binário │ Maior (monomorfização) │ Menor │
│ Coleções mistas │ Não │ Sim │
│ Tipos decididos em │ Compilação │ Execução │
│ Tempo de compilação │ Mais lento │ Mais rápido │
│ Restrições │ Nenhuma │ Object safety │
└──────────────────────┴────────────────────────┴───────────────────────┘
Object Safety: Quais Traits Podem Ser dyn?
Nem toda trait pode ser usada como trait object. Uma trait é object-safe se:
- Não retorna
Self - Não tem parâmetros genéricos nos métodos
- Não tem métodos que usam
Sizedcomo bound implícito
// OBJECT-SAFE ✓
trait Desenhavel {
fn desenhar(&self);
fn cor(&self) -> &str;
}
// NÃO OBJECT-SAFE ✗ — retorna Self
trait Clonavel {
fn clonar(&self) -> Self;
}
// NÃO OBJECT-SAFE ✗ — método genérico
trait Serializavel {
fn serializar<W: std::io::Write>(&self, writer: &mut W);
}
Contornando Limitações de Object Safety
trait Animal: std::fmt::Debug {
fn nome(&self) -> &str;
fn som(&self) -> &str;
// Método que retorna Self não pode ser object-safe,
// mas podemos excluí-lo do trait object:
fn duplicar(&self) -> Self
where
Self: Sized; // Este método não estará disponível via dyn Animal
}
#[derive(Debug, Clone)]
struct Gato {
nome: String,
}
impl Animal for Gato {
fn nome(&self) -> &str { &self.nome }
fn som(&self) -> &str { "Miau" }
fn duplicar(&self) -> Self { self.clone() }
}
#[derive(Debug, Clone)]
struct Cachorro {
nome: String,
}
impl Animal for Cachorro {
fn nome(&self) -> &str { &self.nome }
fn som(&self) -> &str { "Au au" }
fn duplicar(&self) -> Self { self.clone() }
}
fn listar_animais(animais: &[Box<dyn Animal>]) {
for animal in animais {
println!("{} faz {}", animal.nome(), animal.som());
// animal.duplicar(); // NÃO disponível via dyn — e tudo bem!
}
}
fn main() {
let animais: Vec<Box<dyn Animal>> = vec![
Box::new(Gato { nome: "Mimi".into() }),
Box::new(Cachorro { nome: "Rex".into() }),
];
listar_animais(&animais);
}
Veja E0038: Trait Object Unsafe para mais detalhes sobre esse erro.
Padrões Avançados
Enum como Alternativa a Trait Objects
Quando os tipos são conhecidos em tempo de compilação, um enum pode ser mais eficiente:
enum FormaEnum {
Circulo { raio: f64 },
Retangulo { largura: f64, altura: f64 },
Triangulo { base: f64, altura: f64 },
}
impl FormaEnum {
fn area(&self) -> f64 {
match self {
FormaEnum::Circulo { raio } => std::f64::consts::PI * raio * raio,
FormaEnum::Retangulo { largura, altura } => largura * altura,
FormaEnum::Triangulo { base, altura } => base * altura / 2.0,
}
}
fn nome(&self) -> &str {
match self {
FormaEnum::Circulo { .. } => "Círculo",
FormaEnum::Retangulo { .. } => "Retângulo",
FormaEnum::Triangulo { .. } => "Triângulo",
}
}
}
fn main() {
let formas = vec![
FormaEnum::Circulo { raio: 5.0 },
FormaEnum::Retangulo { largura: 4.0, altura: 6.0 },
FormaEnum::Triangulo { base: 3.0, altura: 8.0 },
];
for forma in &formas {
println!("{}: {:.2}", forma.nome(), forma.area());
}
}
Vantagens do enum: sem alocação heap, sem indireção, pattern matching exaustivo. Desvantagem: não é extensível — adicionar um tipo requer modificar o enum.
impl Trait em Posição de Retorno
trait Processador {
fn processar(&self, dados: &[f64]) -> f64;
}
struct Media;
struct Soma;
impl Processador for Media {
fn processar(&self, dados: &[f64]) -> f64 {
if dados.is_empty() { return 0.0; }
dados.iter().sum::<f64>() / dados.len() as f64
}
}
impl Processador for Soma {
fn processar(&self, dados: &[f64]) -> f64 {
dados.iter().sum()
}
}
// Retorno estático — o compilador sabe o tipo exato
fn criar_processador(tipo: &str) -> Box<dyn Processador> {
match tipo {
"media" => Box::new(Media),
"soma" => Box::new(Soma),
_ => panic!("Processador desconhecido: {}", tipo),
}
}
// impl Trait em retorno — quando só há um tipo possível
fn criar_somador() -> impl Processador {
Soma
}
fn main() {
let dados = vec![10.0, 20.0, 30.0, 40.0, 50.0];
let proc_media = criar_processador("media");
let proc_soma = criar_processador("soma");
println!("Média: {:.1}", proc_media.processar(&dados));
println!("Soma: {:.1}", proc_soma.processar(&dados));
let somador = criar_somador();
println!("Somador: {:.1}", somador.processar(&dados));
}
Múltiplos Traits com dyn
use std::fmt;
trait Registravel: fmt::Display + fmt::Debug {
fn nivel(&self) -> &str;
}
#[derive(Debug)]
struct LogInfo {
mensagem: String,
}
impl fmt::Display for LogInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[INFO] {}", self.mensagem)
}
}
impl Registravel for LogInfo {
fn nivel(&self) -> &str { "info" }
}
#[derive(Debug)]
struct LogErro {
mensagem: String,
codigo: u32,
}
impl fmt::Display for LogErro {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[ERRO-{}] {}", self.codigo, self.mensagem)
}
}
impl Registravel for LogErro {
fn nivel(&self) -> &str { "erro" }
}
fn registrar(log: &dyn Registravel) {
println!("[{}] {}", log.nivel(), log);
}
fn main() {
let logs: Vec<Box<dyn Registravel>> = vec![
Box::new(LogInfo { mensagem: "Servidor iniciado".into() }),
Box::new(LogErro { mensagem: "Conexão recusada".into(), codigo: 503 }),
Box::new(LogInfo { mensagem: "Requisição processada".into() }),
];
for log in &logs {
registrar(log.as_ref());
}
}
Performance: Quando a Diferença Importa?
Na maioria dos casos, a diferença de performance entre generics e trait objects é insignificante. O dispatch dinâmico custa apenas uma indireção a mais por chamada de método. A diferença se torna relevante quando:
- Hot loops com milhões de iterações chamando métodos via trait object
- Código numérico/SIMD onde inlining é essencial para autovectorização
- Chamadas frequentes a métodos pequenos onde o overhead relativo é alto
trait Operacao {
fn aplicar(&self, x: f64) -> f64;
}
struct Dobrar;
impl Operacao for Dobrar {
fn aplicar(&self, x: f64) -> f64 { x * 2.0 }
}
// Com generics: o compilador pode fazer inline de aplicar()
fn processar_generico<T: Operacao>(op: &T, dados: &[f64]) -> Vec<f64> {
dados.iter().map(|x| op.aplicar(*x)).collect()
}
// Com trait object: cada chamada passa pela vtable
fn processar_dinamico(op: &dyn Operacao, dados: &[f64]) -> Vec<f64> {
dados.iter().map(|x| op.aplicar(*x)).collect()
}
fn main() {
let dados: Vec<f64> = (0..1000).map(|i| i as f64).collect();
let dobrar = Dobrar;
let r1 = processar_generico(&dobrar, &dados);
let r2 = processar_dinamico(&dobrar, &dados);
assert_eq!(r1, r2);
println!("Ambos produzem o mesmo resultado: {} elementos", r1.len());
}
Quando Usar Cada Abordagem
Decisão: Generics ou Trait Objects?
│
├─ Os tipos são conhecidos em compilação?
│ ├─ Sim, e são poucos → Use enum (melhor performance)
│ └─ Sim, mas são muitos → Use generics (impl Trait)
│
├─ Precisa de coleção heterogênea (Vec<dyn T>)?
│ └─ Sim → Use trait objects (Box<dyn T>)
│
├─ Performance é crítica (hot path)?
│ └─ Sim → Use generics
│
├─ Quer minimizar tamanho do binário?
│ └─ Sim → Use trait objects
│
└─ O tipo é decidido por input do usuário em runtime?
└─ Sim → Use trait objects
Erros Comuns
1. Esquecer dyn antes do trait
// ERRADO (warning desde Rust 2021)
// fn processar(item: &Forma) { }
// CORRETO
// fn processar(item: &dyn Forma) { }
2. Tentar usar trait não object-safe como dyn
Se você encontrar o erro E0038, veja E0038: Trait Object Unsafe para soluções detalhadas.
3. Confundir impl Trait com dyn Trait
# trait Forma { fn area(&self) -> f64; }
# struct Circulo { raio: f64 }
# struct Retangulo { largura: f64, altura: f64 }
# impl Forma for Circulo { fn area(&self) -> f64 { std::f64::consts::PI * self.raio * self.raio } }
# impl Forma for Retangulo { fn area(&self) -> f64 { self.largura * self.altura } }
// impl Trait: retorna UM tipo concreto (decidido em compilação)
fn criar_forma_fixa() -> impl Forma {
Circulo { raio: 1.0 }
// Não pode retornar Retangulo aqui — o tipo é fixo
}
// dyn Trait: pode retornar DIFERENTES tipos (decidido em execução)
fn criar_forma_dinamica(tipo: &str) -> Box<dyn Forma> {
match tipo {
"circulo" => Box::new(Circulo { raio: 1.0 }),
"retangulo" => Box::new(Retangulo { largura: 2.0, altura: 3.0 }),
_ => panic!("Tipo desconhecido"),
}
}
fn main() {
let f1 = criar_forma_fixa();
println!("Forma fixa: {:.2}", f1.area());
let f2 = criar_forma_dinamica("retangulo");
println!("Forma dinâmica: {:.2}", f2.area());
}
Veja Também
- Traits e Generics em Rust — tutorial introdutório sobre traits e generics
- Smart Pointers em Rust —
Box<dyn Trait>em detalhes - Pattern Matching Avançado em Rust — alternativa com enums
- E0038: Trait Object Unsafe — quando traits não podem ser usadas como dyn
- E0277: Trait Não Implementado — quando faltam implementações