Trait Objects vs Generics em Rust | Rust Brasil

Trait objects (dyn) vs generics em Rust: dispatch estático vs dinâmico, performance, quando usar cada abordagem.

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:

  1. Não retorna Self
  2. Não tem parâmetros genéricos nos métodos
  3. Não tem métodos que usam Sized como 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:

  1. Hot loops com milhões de iterações chamando métodos via trait object
  2. Código numérico/SIMD onde inlining é essencial para autovectorização
  3. 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