Nesta aula, vamos explorar trait objects e a palavra-chave dyn em Rust. Trait objects permitem polimorfismo em tempo de execução, ao contrário do polimorfismo estático via generics. Compreender as diferenças entre despacho dinâmico e estático é crucial para escrever código flexível e eficiente.

Até agora, usamos traits principalmente com generics, onde o compilador gera código especializado para cada tipo concreto. Isso é chamado de despacho estático. Trait objects, por outro lado, usam despacho dinâmico, onde a implementação correta é resolvida em tempo de execução através de uma vtable. Veremos como criar trait objects com dyn Trait, alocá-los na heap com Box e analisar os custos envolvidos.

Despacho dinâmico vs estático

Em Rust, o polimorfismo pode ser alcançado de duas formas: estático (via generics) e dinâmico (via trait objects). No despacho estático, o compilador gera código separado para cada tipo concreto que implementa um trait. Isso resulta em chamadas de função diretas, sem sobrecarga em tempo de execução, mas pode aumentar o tamanho do binário (monomorfização). Já no despacho dinâmico, uma única função é compilada, e a implementação correta é determinada em tempo de execução através de uma tabela de funções (vtable).

O despacho estático é preferível quando o número de tipos é pequeno e conhecido em tempo de compilação, pois permite otimizações como inline. O despacho dinâmico é útil quando você precisa armazenar diferentes tipos em uma mesma estrutura de dados (ex.: uma lista de objetos que implementam um trait) ou quando o tipo real só é conhecido em tempo de execução.

// Despacho estático (generics)
fn print_static<T: Display>(item: T) {
    println!("{}", item);
}

// Despacho dinâmico (trait object)
fn print_dynamic(item: &dyn Display) {
    println!("{}", item);
}

dyn Trait

A palavra-chave dyn é usada para criar um trait object. Um trait object é um valor opaco que aponta para uma implementação concreta do trait. A sintaxe é dyn Trait, e pode ser usado como tipo para referências (&dyn Trait) ou ponteiros inteligentes (Box<dyn Trait>, Rc<dyn Trait>). O trait object consiste em um ponteiro para os dados e um ponteiro para a vtable, que contém as implementações dos métodos do trait.

Importante: nem todo trait pode ser usado como trait object. O trait deve ser object-safe. Um trait é object-safe se todos os seus métodos não retornarem Self por valor e não tiverem parâmetros genéricos (exceto em métodos que não são despachados dinamicamente). Métodos que retornam Self ou são genéricos não podem ser chamados via trait object, pois o tipo concreto é desconhecido.

trait Animal {
    fn fazer_som(&self);
}

struct Cachorro;
struct Gato;

impl Animal for Cachorro {
    fn fazer_som(&self) {
        println!("Au au");
    }
}

impl Animal for Gato {
    fn fazer_som(&self) {
        println!("Miau");
    }
}

fn emitir_som(animal: &dyn Animal) {
    animal.fazer_som();
}

Box<dyn Trait>

Para armazenar trait objects na heap, usamos Box<dyn Trait>. Isso é útil quando precisamos de propriedade sobre o valor e queremos que o tamanho seja conhecido em tempo de compilação (pois Box é um ponteiro). Por exemplo, podemos ter um vetor de Box<dyn Animal> contendo diferentes animais.

Ao contrário de referências, Box<dyn Trait> permite que o trait object seja movido e tenha posse exclusiva. O valor é alocado na heap, e o Box é o dono; quando o Box é dropado, o valor também é dropado.

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

    for animal in &animais {
        animal.fazer_som();
    }
}

Custos

Usar trait objects envolve alguns custos em comparação com generics. Primeiro, há uma indireção de ponteiro: para acessar os dados e chamar métodos, o programa segue dois ponteiros (dados e vtable). Isso pode prejudicar a localidade do cache e impedir que o compilador inline as chamadas. Segundo, a alocação na heap (quando usado Box) tem custo de alocação e desalocação.

Além disso, trait objects não podem ser usados em todos os contextos. Por exemplo, você não pode ter um trait object de um trait que não é object-safe. Também não é possível chamar métodos que retornam Self ou que são genéricos (a menos que o método seja implementado de forma especial, como em IntoIterator).

Em geral, a recomendação é usar generics (despacho estático) sempre que possível, e reservar trait objects para situações onde a flexibilidade em tempo de execução é necessária, como em listas heterogêneas, padrões de design como Strategy, ou quando o número de tipos é grande e desconhecido em tempo de compilação.

// Comparação de desempenho (conceitual)
// Usando generics:
fn process_static<T: Processor>(items: Vec<T>) { ... }
// Usando trait objects:
fn process_dynamic(items: Vec<Box<dyn Processor>>) { ... }

Boas práticas

Ao trabalhar com trait objects, lembre-se de que eles só podem ser usados com traits object-safe. Evite trait objects se o desempenho for crítico e o número de tipos for pequeno. Prefira &dyn Trait para empréstimos temporários e Box<dyn Trait> para propriedade. Considere usar enumerações quando o conjunto de tipos for fechado, pois enums são mais eficientes.

Referências

Exercícios

  1. Crie um trait chamado Forma com um método area(&self) -> f64. Implemente para Circulo (raio) e Retangulo (largura, altura). Escreva uma função que aceita um trait object &dyn Forma e imprime a área.

    ✓ Resposta:
    trait Forma {
        fn area(&self) -> f64;
    }
    
    struct Circulo {
        raio: f64,
    }
    
    impl Forma for Circulo {
        fn area(&self) -> f64 {
            std::f64::consts::PI * self.raio * self.raio
        }
    }
    
    struct Retangulo {
        largura: f64,
        altura: f64,
    }
    
    impl Forma for Retangulo {
        fn area(&self) -> f64 {
            self.largura * self.altura
        }
    }
    
    fn imprimir_area(forma: &dyn Forma) {
        println!("Área: {}", forma.area());
    }
    
    fn main() {
        let c = Circulo { raio: 2.0 };
        let r = Retangulo { largura: 3.0, altura: 4.0 };
        imprimir_area(&c);
        imprimir_area(&r);
    }
    
  2. Modifique o exercício anterior para usar Box<dyn Forma> e armazene várias formas em um vetor. Itere sobre o vetor e imprima a área de cada uma.

    ✓ Resposta:
    fn main() {
        let formas: Vec<Box<dyn Forma>> = vec![
            Box::new(Circulo { raio: 2.0 }),
            Box::new(Retangulo { largura: 3.0, altura: 4.0 }),
            Box::new(Circulo { raio: 1.5 }),
        ];
    
        for forma in &formas {
            imprimir_area(forma.as_ref());
        }
    }
    
  3. Explique por que o seguinte código não compila e sugira uma correção:

    trait Clone {
        fn clone(&self) -> Self;
    }
    fn clone_box(obj: Box<dyn Clone>) -> Box<dyn Clone> {
        Box::new(obj.clone())
    }

    ✓ Resposta:

    O código não compila porque o trait Clone não é object-safe: seu método clone retorna Self, o que não é permitido em trait objects. A correção seria usar um trait que não retorne Self, ou usar generics. Por exemplo:

    trait CloneBoxed {
        fn clone_boxed(&self) -> Box<dyn CloneBoxed>;
    }
    
    impl CloneBoxed for i32 {
        fn clone_boxed(&self) -> Box<dyn CloneBoxed> {
            Box::new(*self)
        }
    }
    
    fn clone_box(obj: Box<dyn CloneBoxed>) -> Box<dyn CloneBoxed> {
        obj.clone_boxed()
    }
    
  4. Crie um trait Logger com um método log(&self, msg: &str). Implemente para ConsoleLogger (imprime no console) e FileLogger (escreve em um arquivo, apenas simule com println). Use Box<dyn Logger> em uma struct Aplicacao e chame o log.

    ✓ Resposta:
    trait Logger {
        fn log(&self, msg: &str);
    }
    
    struct ConsoleLogger;
    impl Logger for ConsoleLogger {
        fn log(&self, msg: &str) {
            println!("Console: {}", msg);
        }
    }
    
    struct FileLogger;
    impl Logger for FileLogger {
        fn log(&self, msg: &str) {
            println!("File: {}", msg); // Simulação
        }
    }
    
    struct Aplicacao {
        logger: Box<dyn Logger>,
    }
    
    impl Aplicacao {
        fn new(logger: Box<dyn Logger>) -> Self {
            Aplicacao { logger }
        }
    
        fn executar(&self) {
            self.logger.log("Aplicação iniciada");
        }
    }
    
    fn main() {
        let app = Aplicacao::new(Box::new(ConsoleLogger));
        app.executar();
    }
    
  5. Escreva uma função genérica maior que retorna o maior de dois valores que implementam PartialOrd. Depois, reescreva-a usando trait objects (&dyn PartialOrd) e explique as diferenças.

    ✓ Resposta:

    Versão genérica:

    fn maior<T: PartialOrd>(a: T, b: T) -> T {
        if a >= b { a } else { b }
    }
    

    Versão com trait objects (não compila porque PartialOrd não é object-safe, pois seus métodos retornam Self). Portanto, não é possível reescrever diretamente. Isso ilustra que nem todos os traits podem ser usados como trait objects.