Trait objects são uma ferramenta poderosa em Rust para obter polimorfismo em tempo de execução, permitindo que diferentes tipos que implementam o mesmo trait sejam tratados de forma uniforme. Enquanto generics fornecem despacho estático (monomorfização), trait objects usam despacho dinâmico via vtable, oferecendo flexibilidade em troca de alguns custos de desempenho. Nesta aula, exploraremos como usar a sintaxe dyn Trait e Box<dyn Trait>, e analisaremos as implicações de cada abordagem.

Despacho dinâmico vs estático

Em Rust, o despacho de métodos pode ser resolvido em tempo de compilação (estático) ou em tempo de execução (dinâmico). O despacho estático ocorre com tipos concretos ou generics: o compilador gera código específico para cada tipo usado, resultando em chamadas diretas sem overhead. Já o despacho dinâmico acontece quando o tipo exato não é conhecido em tempo de compilação; o método é resolvido indiretamente através de uma vtable (tabela de funções virtuais), introduzindo um pequeno custo de indireção.

Generics com trait bounds são a forma mais comum de despacho estático. Por exemplo:

fn print_area<T: Shape>(shape: &T) {
    println!("Área: {}", shape.area());
}

Aqui, o compilador gera uma versão de print_area para cada tipo concreto que implementa Shape. Isso é eficiente, mas pode aumentar o tamanho do binário (monomorfização) e não permite armazenar diferentes tipos em uma mesma coleção.

Já com trait objects, escrevemos:

fn print_area(shape: &dyn Shape) {
    println!("Área: {}", shape.area());
}

Isso aceita qualquer referência a um tipo que implemente Shape, mas a chamada a area() é feita via vtable, com uma indireção. A escolha entre estático e dinâmico depende da necessidade de flexibilidade versus desempenho.

dyn Trait

A palavra-chave dyn é usada para criar um trait object. A sintaxe &dyn Trait, Box<dyn Trait>, Rc<dyn Trait> etc. indica que estamos usando um trait object. O trait precisa ser object-safe (seguro para objeto), ou seja, não pode conter métodos que retornam Self por valor, não pode ter parâmetros genéricos (exceto em métodos que não são object-safe), entre outras restrições.

trait Animal {
    fn fazer_som(&self) -> String;
}

struct Cachorro;
struct Gato;

impl Animal for Cachorro {
    fn fazer_som(&self) -> String { "Au au".to_string() }
}

impl Animal for Gato {
    fn fazer_som(&self) -> String { "Miau".to_string() }
}

fn emitir_som(animal: &dyn Animal) {
    println!("{}", animal.fazer_som());
}

fn main() {
    let dog = Cachorro;
    let cat = Gato;
    emitir_som(&dog);
    emitir_som(&cat);
}

No exemplo, &dyn Animal é um trait object que aponta para um valor concreto e sua vtable. O método fazer_som é resolvido dinamicamente.

Box<dyn Trait>

Para armazenar trait objects no heap, usamos Box<dyn Trait>. Isso é útil quando precisamos de posse (ownership) e queremos que o objeto viva além do escopo atual, ou para criar coleções heterogêneas.

trait Animal {
    fn fazer_som(&self) -> String;
}

struct Cachorro;
struct Gato;

impl Animal for Cachorro {
    fn fazer_som(&self) -> String { "Au au".to_string() }
}

impl Animal for Gato {
    fn fazer_som(&self) -> String { "Miau".to_string() }
}

fn main() {
    let animais: Vec<Box<dyn Animal>> = vec![
        Box::new(Cachorro),
        Box::new(Gato),
    ];

    for animal in &animais {
        println!("{}", animal.fazer_som());
    }
}

Aqui, Vec<Box<dyn Animal>> armazena diferentes tipos que implementam Animal. Cada elemento é um ponteiro inteligente para um valor no heap mais a vtable. Isso permite iteração polimórfica, mas cada acesso envolve um desreferenciamento e uma chamada via vtable.

Também é possível usar Box<dyn Trait> como tipo de retorno de função:

fn criar_animal(tipo: &str) -> Box<dyn Animal> {
    match tipo {
        "cachorro" => Box::new(Cachorro),
        "gato" => Box::new(Gato),
        _ => panic!("Tipo desconhecido"),
    }
}

Custos

Usar trait objects introduz alguns custos em relação ao despacho estático:

  • Indireção de ponteiro: Acesso ao dado via ponteiro (ex: &dyn Trait ou Box<dyn Trait>) e à vtable.
  • Alocação no heap: Box<dyn Trait> requer alocação dinâmica, que é mais lenta que a stack.
  • Perda de otimizações: O compilador não pode inlinear chamadas de métodos via vtable, pois o tipo alvo é desconhecido.
  • Tamanho do trait object: Um trait object ocupa dois ponteiros (dado + vtable), então &dyn Trait tem 16 bytes em sistemas 64-bit.

Em contraste, generics com monomorfização geram código especializado, permitindo inlining e sem alocação extra. No entanto, o binário pode crescer e a compilação pode ser mais lenta. Para a maioria dos casos, o custo do despacho dinâmico é pequeno, mas em loops internos ou código crítico de desempenho, pode ser significativo.

Uma boa prática é usar trait objects apenas quando a flexibilidade for necessária (ex: plugins, coleções heterogêneas, callbacks). Caso contrário, prefira generics.

Boas práticas

  • Garanta que o trait seja object-safe: evite métodos que retornam Self ou que tenham parâmetros genéricos (a menos que sejam métodos que não precisam ser object-safe).
  • Use &dyn Trait quando uma referência temporária for suficiente; prefira Box<dyn Trait> quando precisar de posse.
  • Considere usar enumerações (enums) como alternativa para coleções heterogêneas com número limitado de variantes, pois evitam alocação no heap e indireção.
  • Meça o desempenho se o uso de trait objects estiver em caminho crítico.

Referências

Exercícios

  1. Crie um trait Forma com um método area(&self) -> f64. Implemente para Quadrado e Circulo. Escreva uma função que aceita &dyn Forma e imprime a área. Teste com ambos os tipos.

    ✓ Resposta:
    trait Forma {
        fn area(&self) -> f64;
    }
    
    struct Quadrado { lado: f64 }
    struct Circulo { raio: f64 }
    
    impl Forma for Quadrado {
        fn area(&self) -> f64 { self.lado * self.lado }
    }
    
    impl Forma for Circulo {
        fn area(&self) -> f64 { std::f64::consts::PI * self.raio * self.raio }
    }
    
    fn imprimir_area(forma: &dyn Forma) {
        println!("Área: {}", forma.area());
    }
    
    fn main() {
        let q = Quadrado { lado: 2.0 };
        let c = Circulo { raio: 1.0 };
        imprimir_area(&q);
        imprimir_area(&c);
    }
  2. Modifique o exercício anterior para usar Box<dyn Forma> em um vetor. Armazene um quadrado e um círculo, depois itere e imprima as áreas.

    ✓ Resposta:
    trait Forma {
        fn area(&self) -> f64;
    }
    
    struct Quadrado { lado: f64 }
    struct Circulo { raio: f64 }
    
    impl Forma for Quadrado {
        fn area(&self) -> f64 { self.lado * self.lado }
    }
    
    impl Forma for Circulo {
        fn area(&self) -> f64 { std::f64::consts::PI * self.raio * self.raio }
    }
    
    fn main() {
        let formas: Vec<Box<dyn Forma>> = vec![
            Box::new(Quadrado { lado: 2.0 }),
            Box::new(Circulo { raio: 1.0 }),
        ];
    
        for forma in &formas {
            println!("Área: {}", forma.area());
        }
    }
  3. Explique por que o seguinte código não compila e como corrigi-lo usando trait objects:

    trait Animal {
        fn clone(&self) -> Self;
    }
    
    fn main() {
        let animais: Vec<Box<dyn Animal>> = vec![];
    }

    ✓ Resposta:

    O método clone retorna Self, o que torna o trait não object-safe. Para usar trait objects, precisamos remover ou modificar esse método. Uma alternativa é usar Box<dyn Animal> e retornar Box<dyn Animal>:

    trait Animal {
        fn clone_box(&self) -> Box<dyn Animal>;
    }
    
    struct Cachorro;
    impl Animal for Cachorro {
        fn clone_box(&self) -> Box<dyn Animal> { Box::new(Cachorro) }
    }
  4. Escreva uma função escolher_animal que recebe uma string (&str) e retorna Box<dyn Animal> (usando o trait do exemplo da aula). Teste com "cachorro" e "gato".

    ✓ Resposta:
    trait Animal {
        fn fazer_som(&self) -> String;
    }
    
    struct Cachorro;
    struct Gato;
    
    impl Animal for Cachorro {
        fn fazer_som(&self) -> String { "Au au".to_string() }
    }
    
    impl Animal for Gato {
        fn fazer_som(&self) -> String { "Miau".to_string() }
    }
    
    fn escolher_animal(tipo: &str) -> Box<dyn Animal> {
        match tipo {
            "cachorro" => Box::new(Cachorro),
            "gato" => Box::new(Gato),
            _ => panic!("Tipo desconhecido"),
        }
    }
    
    fn main() {
        let a = escolher_animal("cachorro");
        println!("{}", a.fazer_som());
        let b = escolher_animal("gato");
        println!("{}", b.fazer_som());
    }
  5. Compare o uso de generics vs trait objects para uma função que aceita qualquer tipo que implemente Display e imprime o valor. Crie ambas as versões e discuta as diferenças de desempenho e flexibilidade.

    ✓ Resposta:
    // Versão genérica (despacho estático)
    fn print_generic<T: std::fmt::Display>(val: &T) {
        println!("{}", val);
    }
    
    // Versão trait object (despacho dinâmico)
    fn print_dyn(val: &dyn std::fmt::Display) {
        println!("{}", val);
    }
    
    fn main() {
        print_generic(&42);
        print_dyn(&42);
        print_generic(&"hello");
        print_dyn(&"hello");
    }

    A versão genérica é mais rápida pois permite inlining e não tem indireção, mas cada chamada com tipo diferente gera uma instância separada da função (monomorfização). A versão com trait object é mais flexível (pode ser usada em contextos onde o tipo não é conhecido em tempo de compilação), mas tem um pequeno overhead de indireção via vtable.