Nesta aula, vamos aprofundar o uso de trait bounds em Rust, uma ferramenta essencial para escrever código genérico seguro e expressivo. Você aprenderá a restringir tipos genéricos com traits, a usar a sintaxe where para tornar as assinaturas mais legíveis, o recurso impl Trait para simplificar parâmetros e retornos, e como combinar múltiplos bounds. Ao final, você será capaz de projetar funções e tipos que são flexíveis sem perder a segurança de tipo.

Dominar trait bounds é fundamental para trabalhar com coleções, algoritmos e abstrações em Rust. Eles permitem que você especifique exatamente quais capacidades um tipo genérico deve ter, enquanto a sintaxe where ajuda a organizar restrições complexas. Vamos explorar cada conceito com exemplos práticos.

Restrições em genéricos

Em Rust, quando definimos uma função ou struct genérica, podemos restringir os tipos permitidos usando trait bounds. Um trait bound especifica que um tipo deve implementar um determinado trait para ser usado. Por exemplo, se queremos uma função que imprima um valor, podemos exigir que o tipo implemente Display.

Sem trait bounds, o compilador não sabe quais métodos um tipo genérico possui. Com bounds, ganhamos acesso aos métodos do trait e garantimos que apenas tipos compatíveis sejam aceitos. A sintaxe básica é T: Trait após o nome do parâmetro de tipo.

fn print_value<T: std::fmt::Display>(value: T) {
    println!("{}", value);
}

fn main() {
    print_value(42);
    print_value("hello");
}

No exemplo, T: std::fmt::Display é o bound. A função só aceita tipos que implementam Display, como i32 e &str. Se tentarmos passar um tipo que não implementa, como Vec<i32>, o compilador rejeitará.

Também podemos usar bounds em structs e enums. Por exemplo, uma struct que armazena um valor clonável:

#[derive(Debug)]
struct Wrapper<T: Clone> {
    value: T,
}

impl<T: Clone> Wrapper<T> {
    fn new(value: T) -> Self {
        Wrapper { value }
    }
}

fn main() {
    let w = Wrapper::new(5);
    let w2 = w.value.clone();
    println!("{:?}", w);
}

Note que o bound aparece tanto na definição da struct quanto no bloco impl. Isso garante que métodos que usam Clone estejam disponíveis apenas quando o tipo o implementa.

Sintaxe where

Quando as restrições se tornam complexas (múltiplos bounds, lifetimes, etc.), a sintaxe inline pode dificultar a leitura. A sintaxe where permite escrever os bounds após a lista de parâmetros, melhorando a clareza. É especialmente útil em funções com muitos parâmetros genéricos.

A estrutura geral é: fn nome<T, U>(...) where T: Trait1 + Trait2, U: Trait3 { ... }. Vamos comparar:

// Sem where
fn compare_display<T: std::fmt::Display + PartialOrd, U: std::fmt::Display + PartialOrd>(a: T, b: U) -> bool {
    a.to_string() == b.to_string()
}

// Com where
fn compare_display<T, U>(a: T, b: U) -> bool
where
    T: std::fmt::Display + PartialOrd,
    U: std::fmt::Display + PartialOrd,
{
    a.to_string() == b.to_string()
}

A segunda versão é mais fácil de ler, especialmente quando os bounds são longos. A sintaxe where também é obrigatória quando usamos lifetimes com bounds, como T: 'a.

Outro uso comum é em implementações de traits para tipos genéricos:

struct Pair<T> {
    first: T,
    second: T,
}

impl<T> Pair<T>
where
    T: std::fmt::Display + PartialOrd,
{
    fn compare(&self) {
        if self.first > self.second {
            println!("First is greater: {}", self.first);
        } else {
            println!("Second is greater or equal: {}", self.second);
        }
    }
}

fn main() {
    let p = Pair { first: 10, second: 20 };
    p.compare();
}

Aqui, o método compare só existe se T implementar Display e PartialOrd.

impl Trait

O Rust oferece uma sintaxe mais concisa para casos comuns: impl Trait. Em argumentos de função, impl Trait é equivalente a um parâmetro genérico com um bound, mas oculta o nome do tipo. É útil quando você não precisa se referir ao tipo explicitamente.

Por exemplo, uma função que aceita qualquer tipo que implemente Display pode ser escrita como:

fn print_it(value: impl std::fmt::Display) {
    println!("{}", value);
}

fn main() {
    print_it(42);
    print_it("hello");
}

Isso é equivalente a fn print_it<T: std::fmt::Display>(value: T). A diferença é que com impl Trait não podemos nomear o tipo T; isso limita alguns usos (por exemplo, não podemos retornar o mesmo tipo em dois lugares diferentes).

Em posição de retorno, impl Trait permite retornar um tipo concreto que implementa um trait sem expor o tipo exato. Isso é comum em closures e iteradores:

fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
    move |y| x + y
}

fn main() {
    let add_five = make_adder(5);
    println!("{}", add_five(3)); // 8
}

Aqui, o tipo retornado é anônimo (uma closure), mas sabemos que implementa Fn(i32) -> i32. Isso simplifica a assinatura.

Importante: impl Trait em argumentos é açúcar sintático para genéricos, mas em retorno é uma característica especial que só funciona com um único tipo concreto (não pode ser usado para retornar tipos diferentes condicionalmente).

Múltiplos bounds

Muitas vezes precisamos que um tipo implemente vários traits ao mesmo tempo. Usamos o operador + para combinar bounds. Por exemplo, T: Display + Clone exige que T implemente ambos.

Vamos criar uma função que imprime um valor e depois o clona:

use std::fmt::Display;

fn print_and_clone<T: Display + Clone>(value: T) -> T {
    println!("{}", value);
    value.clone()
}

fn main() {
    let cloned = print_and_clone(42);
    println!("Cloned: {}", cloned);
}

Também podemos combinar traits com lifetimes. Por exemplo, T: Display + 'static exige que T não contenha referências não-estáticas.

Em structs, múltiplos bounds podem ser escritos inline ou com where:

struct Container<T>
where
    T: Display + Clone + Debug,
{
    data: T,
}

impl<T> Container<T>
where
    T: Display + Clone + Debug,
{
    fn show(&self) {
        println!("{:?}", self.data);
    }
}

fn main() {
    let c = Container { data: "hello" };
    c.show();
}

Note que os bounds se repetem na struct e no impl. Para evitar repetição, podemos usar um alias de trait (não nativo) ou definir um supertrait. Mas isso é avançado; por enquanto, a repetição é aceitável.

Boas práticas

1. Prefira a sintaxe where quando houver múltiplos bounds ou lifetimes, pois melhora a legibilidade.
2. Use impl Trait em argumentos quando o nome do tipo não for necessário; em retornos, use com cuidado, pois limita o tipo concreto.
3. Evite bounds desnecessários: só exija o que realmente precisa. Isso mantém o código mais flexível.
4. Em structs, considere se o bound é realmente necessário na definição ou apenas em alguns métodos. Às vezes é melhor deixar a struct sem bounds e adicioná-los apenas nos métodos que precisam.

Referências

Exercícios

  1. Escreva uma função genérica maior que receba dois valores do mesmo tipo e retorne o maior, usando um trait bound apropriado. Teste com números inteiros e strings.

    ✓ Resposta:
    fn maior<T: PartialOrd>(a: T, b: T) -> T {
        if a >= b { a } else { b }
    }
    
    fn main() {
        println!("{}", maior(10, 20));
        println!("{}", maior("abc", "xyz"));
    }
  2. Converta a função do exercício 1 para usar a sintaxe where.

    ✓ Resposta:
    fn maior<T>(a: T, b: T) -> T
    where
        T: PartialOrd,
    {
        if a >= b { a } else { b }
    }
    
    fn main() {
        println!("{}", maior(10, 20));
        println!("{}", maior("abc", "xyz"));
    }
  3. Crie uma função exibir_e_clonar que aceite um argumento com impl Display + Clone, imprima o valor e retorne um clone.

    ✓ Resposta:
    use std::fmt::Display;
    
    fn exibir_e_clonar(value: impl Display + Clone) -> impl Display + Clone {
        println!("{}", value);
        value.clone()
    }
    
    fn main() {
        let x = 42;
        let y = exibir_e_clonar(x);
        println!("Clonado: {}", y);
    }
  4. Defina uma struct genérica Ponto<T> com campos x e y. Implemente um método distancia_origem que retorna a distância até a origem (use f64). Exija que T implemente Into<f64> (ou use From). Dica: use where no impl.

    ✓ Resposta:
    struct Ponto<T> {
        x: T,
        y: T,
    }
    
    impl<T> Ponto<T>
    where
        T: Into<f64> + Copy,
    {
        fn distancia_origem(&self) -> f64 {
            let x: f64 = self.x.into();
            let y: f64 = self.y.into();
            (x * x + y * y).sqrt()
        }
    }
    
    fn main() {
        let p = Ponto { x: 3, y: 4 };
        println!("{}", p.distancia_origem()); // 5.0
    }
  5. Escreva uma função somar que aceite dois argumentos de tipos possivelmente diferentes, mas ambos devem implementar std::ops::Add com o mesmo tipo de saída. Use where e defina o tipo de retorno como o tipo de saída da soma. Teste com i32 e f64 (mas note que Add para tipos diferentes não funciona diretamente; use um único tipo genérico para simplificar).

    ✓ Resposta:
    use std::ops::Add;
    
    fn somar<T>(a: T, b: T) -> T
    where
        T: Add<Output = T>,
    {
        a + b
    }
    
    fn main() {
        println!("{}", somar(1, 2));
        println!("{}", somar(1.5, 2.5));
    }