Trait bounds e where
Esta aula cobre como restringir tipos genéricos em Rust usando trait bounds, a sintaxe `where` para clareza, o uso de `impl Trait` em argumentos e retornos, e como combinar múltiplos bounds. O conteúdo inclui exemplos práticos, boas práticas e exercícios com respostas.
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
- The Rust Book: Traits
- Rust by Example: Bounds
- Rust Reference: Where clauses
- Rust Edition Guide: impl Trait
- std::fmt::Display documentation
Exercícios
Escreva uma função genérica
maiorque 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")); }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")); }Crie uma função
exibir_e_clonarque aceite um argumento comimpl 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); }Defina uma struct genérica
Ponto<T>com camposxey. Implemente um métododistancia_origemque retorna a distância até a origem (usef64). Exija queTimplementeInto<f64>(ou useFrom). Dica: usewhereno 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 }Escreva uma função
somarque aceite dois argumentos de tipos possivelmente diferentes, mas ambos devem implementarstd::ops::Addcom o mesmo tipo de saída. Usewheree defina o tipo de retorno como o tipo de saída da soma. Teste comi32ef64(mas note queAddpara 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)); }