Design com traits
Nesta aula, exploramos o design de software em Rust usando traits, focando em composição sobre herança, a diferença entre traits genéricas e objetos de trait, e a importância da coesão. Inclui exemplos práticos e exercícios para consolidar o aprendizado.
Bem-vindo à aula 68 do nosso curso de Rust! Hoje vamos mergulhar em um dos aspectos mais poderosos e característicos da linguagem: o design orientado a traits. Traits são a espinha dorsal da abstração em Rust, permitindo que você defina comportamentos compartilhados entre tipos sem herança de classes. Nesta aula, vamos explorar como usar traits para criar designs elegantes, modulares e coesos, com foco em composição, genéricos e boas práticas.
Se você já vem acompanhando o curso, sabe que Rust não tem herança clássica. Em vez disso, usa traits para compartilhar comportamento. Mas a verdade é que traits vão muito além de simples interfaces: eles permitem composição, polimorfismo estático e dinâmico, e incentivam um design baseado em capacidades, não em hierarquias. Vamos entender isso na prática.
Composição sobre herança
Em linguagens orientadas a objetos tradicionais, como Java ou C++, é comum modelar hierarquias de classes: uma classe Animal, subclasses Cachorro e Gato, e assim por diante. Isso funciona, mas pode levar a problemas como o problema do diamante, acoplamento rígido e dificuldade de reutilização. Rust adota uma abordagem diferente: em vez de herdar comportamento, você compõe comportamentos usando traits. A ideia é que um tipo pode implementar vários traits, cada um representando uma capacidade específica, e você pode combinar esses traits para construir funcionalidades complexas.
Por exemplo, em vez de ter uma hierarquia de animais, você define traits como Falar, Mover, Comer, e então cada tipo (cachorro, gato, robô) implementa os traits que lhe são relevantes. Isso permite que você escreva funções genéricas que aceitam qualquer tipo que implemente Falar, sem se importar com a hierarquia. A composição é mais flexível e evita o acoplamento desnecessário.
trait Falar { fn falar(&self); }
trait Mover { fn mover(&self); }
struct Cachorro;
struct Gato;
impl Falar for Cachorro {
fn falar(&self) { println!("Au au!"); }
}
impl Mover for Cachorro {
fn mover(&self) { println!("Correndo atrás do rabo."); }
}
impl Falar for Gato {
fn falar(&self) { println!("Miau!"); }
}
impl Mover for Gato {
fn mover(&self) { println!("Andando silenciosamente."); }
}
fn apresentar(animal: &(impl Falar + Mover)) {
animal.falar();
animal.mover();
}
fn main() {
let dog = Cachorro;
let cat = Gato;
apresentar(&dog);
apresentar(&cat);
}Neste exemplo, apresentar aceita qualquer tipo que implemente ambos os traits Falar e Mover. Isso é composição em ação: você não precisa que Cachorro e Gato compartilhem uma classe base; eles apenas implementam os mesmos traits. Isso torna o código mais modular e fácil de estender: se você criar um robô que fala e se move, basta implementar os traits para ele, e a função apresentar funcionará com ele também, sem alterações.
Traits genéricas vs objetos
Rust oferece duas maneiras de usar traits para polimorfismo: polimorfismo estático (com genéricos) e polimorfismo dinâmico (com objetos de trait, usando dyn Trait). Cada abordagem tem seus prós e contras, e a escolha depende do contexto. Vamos entender as diferenças.
Traits genéricas (monomorfização): Quando você escreve uma função genérica com um parâmetro que implementa um trait, o compilador gera uma versão especializada da função para cada tipo concreto usado. Isso é chamado de monomorfização. O resultado é código rápido, sem custo de despacho dinâmico, mas o tamanho do binário pode aumentar. Além disso, a função é compilada em tempo de compilação, então o tipo deve ser conhecido em tempo de compilação.
fn imprimir(x: &impl std::fmt::Display) {
println!("{}", x);
}
fn main() {
imprimir(&42);
imprimir(&"Olá");
}Objetos de trait (dyn Trait): Quando você usa dyn Trait, você está usando despacho dinâmico. Nesse caso, a função aceita qualquer tipo que implemente o trait, mas a chamada é resolvida em tempo de execução via vtable. Isso permite armazenar diferentes tipos em uma mesma coleção, por exemplo, mas tem um custo de desempenho pequeno devido à indireção. Além disso, objetos de trait só podem ser usados se o trait for object-safe (sem métodos genéricos, sem Self por valor, etc.).
fn imprimir(x: &dyn std::fmt::Display) {
println!("{}", x);
}
fn main() {
let numero = 42;
let texto = "Olá";
imprimir(&numero);
imprimir(&texto);
}A escolha entre genéricos e objetos de trait depende de suas necessidades: se você precisa de desempenho máximo e os tipos são conhecidos em tempo de compilação, use genéricos. Se você precisa de flexibilidade em tempo de execução (como listas heterogêneas), use objetos de trait. Em muitos casos, você pode combinar ambas: usar genéricos na API pública e objetos de trait internamente.
Coesão
Coesão é um princípio de design que se refere a quão relacionadas são as responsabilidades de um módulo, classe ou trait. Em Rust, ao projetar traits, é importante manter cada trait coeso, ou seja, focado em uma única responsabilidade. Isso facilita a reutilização, a testabilidade e a compreensão do código. Um trait com muitas responsabilidades não relacionadas tende a ser difícil de implementar e de usar.
Por exemplo, em vez de ter um trait Animal com métodos falar, mover, comer, reproduzir, etc., é melhor dividir em traits menores: Falar, Mover, Comer, Reproduzir. Isso permite que um tipo implemente apenas os traits que lhe fazem sentido. Um robô pode implementar Falar e Mover, mas não Comer nem Reproduzir. Isso é mais limpo e evita métodos inúteis.
Além disso, traits coesos são mais fáceis de combinar com genéricos. Você pode escrever funções que exigem apenas Falar ou apenas Mover, sem forçar a implementação de métodos desnecessários. Isso também promove a composição, que vimos antes. Em resumo, coesão é um princípio que se aplica diretamente ao design de traits: cada trait deve ter um propósito claro e bem definido.
Exemplos
Vamos colocar tudo em prática com um exemplo mais completo: um sistema de desenho com formas geométricas. Queremos que diferentes formas possam ser desenhadas em uma tela, e também possam ser calculadas sua área. Vamos usar traits coesos e composição.
// Traits coesos
trait Desenhar {
fn desenhar(&self);
}
trait Area {
fn area(&self) -> f64;
}
// Tipos concretos
struct Circulo { raio: f64 }
struct Retangulo { largura: f64, altura: f64 }
impl Desenhar for Circulo {
fn desenhar(&self) {
println!("Desenhando um círculo de raio {}", self.raio);
}
}
impl Area for Circulo {
fn area(&self) -> f64 {
std::f64::consts::PI * self.raio * self.raio
}
}
impl Desenhar for Retangulo {
fn desenhar(&self) {
println!("Desenhando um retângulo {}x{}", self.largura, self.altura);
}
}
impl Area for Retangulo {
fn area(&self) -> f64 {
self.largura * self.altura
}
}
// Funções genéricas que usam os traits
fn desenhar_figura(figura: &impl Desenhar) {
figura.desenhar();
}
fn calcular_area(figura: &impl Area) -> f64 {
figura.area()
}
// Função que usa ambos os traits (composição)
fn desenhar_e_calcular(figura: &(impl Desenhar + Area)) {
figura.desenhar();
println!("Área: {:.2}", figura.area());
}
fn main() {
let circulo = Circulo { raio: 2.0 };
let retangulo = Retangulo { largura: 3.0, altura: 4.0 };
desenhar_figura(&circulo);
desenhar_figura(&retangulo);
println!("Área do círculo: {:.2}", calcular_area(&circulo));
println!("Área do retângulo: {:.2}", calcular_area(&retangulo));
desenhar_e_calcular(&circulo);
desenhar_e_calcular(&retangulo);
}Neste exemplo, temos dois traits coesos: Desenhar e Area. Cada forma implementa ambos, mas poderíamos ter formas que só desenham (sem área) ou só calculam área (sem desenhar). A função desenhar_e_calcular usa composição, exigindo ambos os traits. Isso é muito flexível e modular.
Podemos também usar objetos de trait para armazenar figuras heterogêneas em uma lista:
fn main() {
let figuras: Vec<Box<dyn Desenhar>> = vec![
Box::new(Circulo { raio: 1.0 }),
Box::new(Retangulo { largura: 2.0, altura: 3.0 }),
];
for figura in figuras {
figura.desenhar();
}
}Isso mostra como objetos de trait permitem polimorfismo dinâmico, útil quando você não conhece os tipos em tempo de compilação ou precisa de uma coleção heterogênea.
Boas práticas e observações finais
Ao projetar com traits, lembre-se de:
- Manter cada trait com uma única responsabilidade (coesão).
- Preferir composição de traits a hierarquias complexas.
- Usar genéricos para desempenho e segurança em tempo de compilação.
- Usar objetos de trait quando precisar de flexibilidade em tempo de execução.
- Documentar seus traits com exemplos e explicar o contrato esperado.
Com essas práticas, você criará APIs elegantes e extensíveis em Rust.
Referências
- The Rust Book - Traits
- The Rust Reference - Traits
- Rust by Example - Traits
- The Rust Book - Trait Objects
- Rust by Example - Trait Objects (dyn)
- The Rust Book - Generic Types, Traits, and Lifetimes
Exercícios
Crie um trait chamado
Somcom um métodoemitir_som. Implemente para um structCachorroe um structGato. Depois, escreva uma função genérica que aceita qualquer tipo que implementeSome chamaemitir_som.✓ Resposta:trait Som { fn emitir_som(&self); } struct Cachorro; struct Gato; impl Som for Cachorro { fn emitir_som(&self) { println!("Au au"); } } impl Som for Gato { fn emitir_som(&self) { println!("Miau"); } } fn fazer_som<T: Som>(animal: &T) { animal.emitir_som(); } fn main() { let dog = Cachorro; let cat = Gato; fazer_som(&dog); fazer_som(&cat); }Explique a diferença entre
fn f<T: Trait>(x: T)efn f(x: &dyn Trait). Dê um exemplo onde cada um é mais adequado.✓ Resposta: A função genéricafn f<T: Trait>(x: T)é monomorfizada em tempo de compilação, resultando em código mais rápido e sem overhead de despacho dinâmico. É mais adequada quando você tem tipos concretos conhecidos e quer máxima performance. Jáfn f(x: &dyn Trait)usa despacho dinâmico, permitindo passar diferentes tipos em tempo de execução, útil para coleções heterogêneas ou quando os tipos só são conhecidos em runtime. Por exemplo, uma função que aceita um iterador de objetosdyn Displayé útil para imprimir qualquer coisa, mas se você sempre imprime números, um genérico é melhor.Projete um sistema com traits
VoareNadar. Crie um structPatoque implementa ambos, e um structAviaoque implementa apenasVoar. Escreva uma função que aceita qualquer tipo que implementeVoare uma que aceiteVoar + Nadar.✓ Resposta:trait Voar { fn voar(&self); } trait Nadar { fn nadar(&self); } struct Pato; struct Aviao; impl Voar for Pato { fn voar(&self) { println!("Pato voa"); } } impl Nadar for Pato { fn nadar(&self) { println!("Pato nada"); } } impl Voar for Aviao { fn voar(&self) { println!("Avião voa"); } } fn voar_somente<T: Voar>(x: &T) { x.voar(); } fn voar_e_nadar<T: Voar + Nadar>(x: &T) { x.voar(); x.nadar(); } fn main() { let pato = Pato; let aviao = Aviao; voar_somente(&pato); voar_somente(&aviao); voar_e_nadar(&pato); // voar_e_nadar(&aviao); // erro: Aviao não implementa Nadar }Explique por que um trait com métodos genéricos não pode ser usado como objeto de trait (
dyn Trait). Dê um exemplo de trait object-safe e um que não é.✓ Resposta: Um trait é object-safe se todos os seus métodos não são genéricos e não usamSelfpor valor (exceto em posições específicas). Métodos genéricos não podem ser despachados dinamicamente porque o compilador não pode gerar uma vtable para um método que aceita tipos arbitrários. Por exemplo,trait Foo { fn bar<T>(&self, x: T); }não é object-safe. Játrait Foo { fn bar(&self); }é object-safe. Isso porque, para chamar um método genérico, o compilador precisa conhecer o tipo concreto em tempo de compilação, o que não acontece com despacho dinâmico.Crie uma trait
Resumocom um métodoresumirque retorna uma String. Implemente para um structArtigoe um structTweet. Depois, crie uma função que aceita um vetor de&dyn Resumoe imprime o resumo de cada um.✓ Resposta:trait Resumo { fn resumir(&self) -> String; } struct Artigo { titulo: String, corpo: String } struct Tweet { usuario: String, conteudo: String } impl Resumo for Artigo { fn resumir(&self) -> String { format!("{}", self.titulo) } } impl Resumo for Tweet { fn resumir(&self) -> String { format!("@{}", self.usuario) } } fn imprimir_resumos(itens: &[&dyn Resumo]) { for item in itens { println!("{}", item.resumir()); } } fn main() { let artigo = Artigo { titulo: String::from("Rust"), corpo: String::from("...") }; let tweet = Tweet { usuario: String::from("user"), conteudo: String::from("...") }; let itens: Vec<&dyn Resumo> = vec![&artigo, &tweet]; imprimir_resumos(&itens); }