Trait objects e dyn
Aula sobre trait objects em Rust, abordando despacho dinâmico vs estático, uso de `dyn Trait`, alocação com `Box<dyn Trait>` e custos associados. Explica como traits podem ser usados para polimorfismo em tempo de execução com exemplos práticos.
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
- The Rust Book - Trait Objects
- Rust Reference - Trait Object Types
- std::boxed::Box
- Rust by Example - Object Safety
- std::dyn
- The Rustonomicon - Object Safety
Exercícios
-
Crie um trait chamado
Formacom um métodoarea(&self) -> f64. Implemente paraCirculo(raio) eRetangulo(largura, altura). Escreva uma função que aceita um trait object&dyn Formae 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); } -
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()); } } -
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
Clonenão é object-safe: seu métodocloneretornaSelf, o que não é permitido em trait objects. A correção seria usar um trait que não retorneSelf, 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() } -
Crie um trait
Loggercom um métodolog(&self, msg: &str). Implemente paraConsoleLogger(imprime no console) eFileLogger(escreve em um arquivo, apenas simule com println). UseBox<dyn Logger>em uma structAplicacaoe 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(); } -
Escreva uma função genérica
maiorque retorna o maior de dois valores que implementamPartialOrd. 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
PartialOrdnão é object-safe, pois seus métodos retornamSelf). Portanto, não é possível reescrever diretamente. Isso ilustra que nem todos os traits podem ser usados como trait objects.