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

Exercícios

  1. Crie um trait chamado Som com um método emitir_som. Implemente para um struct Cachorro e um struct Gato. Depois, escreva uma função genérica que aceita qualquer tipo que implemente Som e chama emitir_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);
    }
  2. Explique a diferença entre fn f<T: Trait>(x: T) e fn f(x: &dyn Trait). Dê um exemplo onde cada um é mais adequado.

    ✓ Resposta: A função genérica fn 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 objetos dyn Display é útil para imprimir qualquer coisa, mas se você sempre imprime números, um genérico é melhor.
  3. Projete um sistema com traits Voar e Nadar. Crie um struct Pato que implementa ambos, e um struct Aviao que implementa apenas Voar. Escreva uma função que aceita qualquer tipo que implemente Voar e uma que aceite Voar + 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
    }
  4. 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 usam Self por 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.
  5. Crie uma trait Resumo com um método resumir que retorna uma String. Implemente para um struct Artigo e um struct Tweet. Depois, crie uma função que aceita um vetor de &dyn Resumo e 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);
    }