Traits em Rust
LidoNesta aula, você aprenderá a definir e implementar traits em Rust, incluindo métodos default e como usar traits como contratos para abstração e reutilização de código. Vamos explorar desde o básico até exemplos práticos, com exercícios para fixar o conteúdo.
Traits são uma das características mais poderosas do Rust, permitindo definir comportamentos compartilhados entre tipos. Eles funcionam como interfaces em outras linguagens, mas com capacidades adicionais, como métodos default e genéricos. Nesta aula, vamos explorar como criar traits, implementá-los para tipos específicos, e utilizar métodos default para fornecer funcionalidades padrão. Além disso, veremos como os traits atuam como contratos, garantindo que tipos que os implementam tenham certos comportamentos.
Vamos começar com a definição de traits, depois implementá-los, entender métodos default e, por fim, aplicar traits em contextos mais amplos, como parâmetros genéricos e objetos trait.
Definindo traits
Um trait define um conjunto de métodos que os tipos podem implementar. A sintaxe básica é usar a palavra-chave trait, seguida pelo nome do trait e um bloco com as assinaturas dos métodos. Os métodos podem ter parâmetros e retorno, mas, por padrão, não têm corpo (a menos que sejam métodos default, que veremos depois).
Os traits são fundamentais para abstração em Rust. Eles permitem escrever código genérico que funciona com qualquer tipo que implemente o trait, sem precisar conhecer o tipo concreto em tempo de compilação. Isso promove reutilização e flexibilidade.
trait Resumo {
fn resumir(&self) -> String;
}
No exemplo acima, definimos um trait chamado Resumo com um método resumir que recebe uma referência a self e retorna uma String. Note que não há implementação, apenas a assinatura. Qualquer tipo que queira implementar Resumo deve fornecer uma implementação para resumir.
Traits também podem ter métodos estáticos (sem self) e podem exigir outros traits (supertraits), mas isso será abordado em aulas futuras. Por enquanto, vamos focar nos métodos de instância.
Implementando traits
Para implementar um trait para um tipo, usamos a sintaxe impl NomeDoTrait for NomeDoTipo. Dentro do bloco, fornecemos as implementações dos métodos exigidos. É importante que o trait esteja no escopo, ou seja, se o trait for definido em outro módulo, precisamos importá-lo.
Vamos implementar o trait Resumo para uma struct Artigo e para uma struct Tweet:
struct Artigo {
titulo: String,
autor: String,
conteudo: String,
}
impl Resumo for Artigo {
fn resumir(&self) -> String {
format!("{}, por {}", self.titulo, self.autor)
}
}
struct Tweet {
usuario: String,
texto: String,
}
impl Resumo for Tweet {
fn resumir(&self) -> String {
format!("{}: {}", self.usuario, self.texto)
}
}
Agora podemos chamar o método resumir em instâncias de Artigo e Tweet, desde que o trait esteja no escopo. Por exemplo:
fn main() {
let artigo = Artigo {
titulo: String::from("Rust"),
autor: String::from("Ana"),
conteudo: String::from("..."),
};
let tweet = Tweet {
usuario: String::from("@dev"),
texto: String::from("Aprendendo Rust!"),
};
println!("{}", artigo.resumir());
println!("{}", tweet.resumir());
}
Isso imprime:
Rust, por Ana
@dev: Aprendendo Rust!
Note que, ao definir a implementação, o tipo não precisa estar no mesmo crate que o trait, desde que pelo menos um dos dois seja local. Isso é a regra de coerência (orphan rule), que veremos mais adiante.
Default methods
Às vezes, queremos fornecer uma implementação padrão para um método, de modo que os tipos que implementam o trait possam optar por não sobrescrevê-la. Isso é útil quando a maioria dos tipos pode usar uma implementação genérica, mas alguns precisam de personalização.
Para definir um método default, basta escrever um corpo para o método na definição do trait. Os tipos que implementam o trait podem usar a implementação padrão ou fornecer a própria.
trait Resumo {
fn resumir(&self) -> String;
fn autor(&self) -> String;
// Método default
fn resumo_completo(&self) -> String {
format!("{} por {}", self.resumir(), self.autor())
}
}
Agora, qualquer tipo que implemente Resumo deve implementar resumir e autor, mas pode usar o resumo_completo padrão. Vamos implementar para Artigo sem sobrescrever resumo_completo:
impl Resumo for Artigo {
fn resumir(&self) -> String {
format!("{}", self.titulo)
}
fn autor(&self) -> String {
self.autor.clone()
}
}
E para Tweet, podemos sobrescrever resumo_completo:
impl Resumo for Tweet {
fn resumir(&self) -> String {
format!("{}: {}", self.usuario, self.texto)
}
fn autor(&self) -> String {
self.usuario.clone()
}
fn resumo_completo(&self) -> String {
format!("Tweet de {}: {}", self.usuario, self.texto)
}
}
Agora, ao chamar resumo_completo em um Artigo, usará a implementação default, enquanto em Tweet usará a personalizada.
Métodos default também podem chamar outros métodos do trait, mesmo que não tenham implementação, desde que os tipos que implementam o trait forneçam essas implementações. Isso permite criar comportamentos complexos com pouco código.
Traits como contratos
Traits servem como contratos: eles definem o que um tipo deve ser capaz de fazer, mas não como. Isso permite que funções e structs genéricas exijam que certos tipos implementem determinados traits, garantindo que as operações necessárias estarão disponíveis.
Por exemplo, podemos escrever uma função genérica que aceita qualquer tipo que implemente Resumo:
fn notificar(item: &impl Resumo) {
println!("Notícia: {}", item.resumir());
}
Ou com a sintaxe de generic:
fn notificar<T: Resumo>(item: &T) {
println!("Notícia: {}", item.resumir());
}
Ambas são equivalentes, mas a primeira é mais concisa. Isso garante que qualquer tipo passado para notificar implemente o trait Resumo, e podemos chamar resumir com segurança.
Também podemos usar traits em structs genéricas:
struct Notificador<T: Resumo> {
item: T,
}
impl<T: Resumo> Notificador<T> {
fn novo(item: T) -> Self {
Notificador { item }
}
fn enviar(&self) {
println!("Enviando: {}", self.item.resumir());
}
}
Além disso, traits podem ser usados como objetos trait (trait objects) para permitir polimorfismo dinâmico. Isso é feito com a sintaxe dyn Trait, mas exige que o trait seja object-safe (sem métodos genéricos, por exemplo). Veremos isso em aulas futuras.
Um aspecto importante dos traits como contratos é a possibilidade de usar traits como parâmetros em funções que retornam valores que implementam um trait, por exemplo, com impl Trait no retorno:
fn cria_resumo() -> impl Resumo {
Tweet {
usuario: String::from("@rust"),
texto: String::from("Olá, Rust!"),
}
}
Isso permite retornar um tipo concreto sem especificar o nome, desde que ele implemente o trait.
Boas práticas e observações
Algumas boas práticas ao trabalhar com traits:
- Mantenha traits pequenos e coesos, cada um com uma responsabilidade clara.
- Use métodos default para fornecer comportamentos que são comuns a muitas implementações, mas permita que tipos específicos os sobrescrevam quando necessário.
- Prefira usar
impl Traitem parâmetros e retornos quando a função for simples, mas em APIs públicas, considere usar genéricos com limites de trait para maior flexibilidade. - Lembre-se da regra de coerência: um trait pode ser implementado para um tipo somente se pelo menos um deles for local ao crate.
- Para traits que devem ser usados como objetos trait, certifique-se de que sejam object-safe (sem métodos genéricos, sem
Selfpor valor em parâmetros, etc.).
Exercícios
Defina um trait chamado
Areaque tenha um métodocalcular_arearetornando f64. Implemente para as structsCirculo(com raio) eRetangulo(com largura e altura).✓ Resposta:trait Area { fn calcular_area(&self) -> f64; } struct Circulo { raio: f64, } struct Retangulo { largura: f64, altura: f64, } impl Area for Circulo { fn calcular_area(&self) -> f64 { 3.14159 * self.raio * self.raio } } impl Area for Retangulo { fn calcular_area(&self) -> f64 { self.largura * self.altura } }Adicione um método default chamado
descricaoao traitAreaque retorna a string "Área calculada". Sobrescreva-o emRetangulopara retornar "Área do retângulo".✓ Resposta:trait Area { fn calcular_area(&self) -> f64; fn descricao(&self) -> String { String::from("Área calculada") } } impl Area for Retangulo { fn calcular_area(&self) -> f64 { self.largura * self.altura } fn descricao(&self) -> String { String::from("Área do retângulo") } }Escreva uma função genérica que aceite qualquer tipo que implemente
Areae imprima a área e a descrição.✓ Resposta:fn imprimir_area(item: &impl Area) { println!("{}: {}", item.descricao(), item.calcular_area()); }Crie uma struct genérica
Containerque armazena um valor que implementaAreae tenha um métodoexibirque imprime a área e a descrição.✓ Resposta:struct Container<T: Area> { valor: T, } impl<T: Area> Container<T> { fn novo(valor: T) -> Self { Container { valor } } fn exibir(&self) { println!("{}: {}", self.valor.descricao(), self.valor.calcular_area()); } }Explique por que traits são chamados de "contratos" e dê um exemplo de como eles garantem que certos métodos existam em tipos genéricos.
✓ Resposta:Traits funcionam como contratos porque definem um conjunto de métodos que qualquer tipo que implemente o trait deve fornecer. Isso garante que, ao usar um tipo genérico com um limite de trait, o compilador sabe que esses métodos existem, permitindo chamá-los com segurança. Por exemplo, se uma função requerT: Resumo, ela pode chamaritem.resumir()sem se preocupar com o tipo concreto, pois o contrato assegura que o método está disponível.