Introdução
Bem-vindo(a) a Advanced Traits. Este laboratório faz parte do Rust Book. Você pode praticar suas habilidades em Rust no LabEx.
Neste laboratório, vamos aprofundar os detalhes mais avançados de traits (características) que foram abordados anteriormente em "Traits: Defining Shared Behavior" (Características: Definindo Comportamento Compartilhado), agora que você tem uma melhor compreensão de Rust.
Advanced Traits (Características Avançadas)
Nós cobrimos as traits (características) pela primeira vez em "Traits: Defining Shared Behavior" (Características: Definindo Comportamento Compartilhado), mas não discutimos os detalhes mais avançados. Agora que você sabe mais sobre Rust, podemos entrar nos detalhes.
Tipos Associados
Tipos associados conectam um espaço reservado de tipo com uma trait (característica) de forma que as definições de método da trait possam usar esses tipos de espaço reservado em suas assinaturas. O implementador de uma trait especificará o tipo concreto a ser usado em vez do tipo de espaço reservado para a implementação específica. Dessa forma, podemos definir uma trait que usa alguns tipos sem precisar saber exatamente quais são esses tipos até que a trait seja implementada.
Descrevemos a maioria dos recursos avançados neste capítulo como raramente necessários. Tipos associados estão em algum lugar no meio: eles são usados com mais raridade do que os recursos explicados no restante do livro, mas mais comumente do que muitos dos outros recursos discutidos neste capítulo.
Um exemplo de uma trait com um tipo associado é a trait Iterator que a biblioteca padrão fornece. O tipo associado é chamado Item e representa o tipo dos valores sobre os quais o tipo que implementa a trait Iterator está iterando. A definição da trait Iterator é mostrada na Listagem 19-12.
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
Listagem 19-12: A definição da trait Iterator que possui um tipo associado Item
O tipo Item é um espaço reservado, e a definição do método next mostra que ele retornará valores do tipo Option<Self::Item>. Os implementadores da trait Iterator especificarão o tipo concreto para Item, e o método next retornará um Option contendo um valor desse tipo concreto.
Tipos associados podem parecer um conceito semelhante a genéricos, na medida em que estes últimos nos permitem definir uma função sem especificar quais tipos ela pode manipular. Para examinar a diferença entre os dois conceitos, vamos analisar uma implementação da trait Iterator em um tipo chamado Counter que especifica que o tipo Item é u32:
Nome do arquivo: src/lib.rs
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
--snip--
Esta sintaxe parece comparável à de genéricos. Então, por que não definir a trait Iterator com genéricos, como mostrado na Listagem 19-13?
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
Listagem 19-13: Uma definição hipotética da trait Iterator usando genéricos
A diferença é que, ao usar genéricos, como na Listagem 19-13, devemos anotar os tipos em cada implementação; porque também podemos implementar Iterator<``String``> for Counter ou qualquer outro tipo, poderíamos ter múltiplas implementações de Iterator para Counter. Em outras palavras, quando uma trait tem um parâmetro genérico, ela pode ser implementada para um tipo várias vezes, alterando os tipos concretos dos parâmetros de tipo genérico a cada vez. Quando usamos o método next em Counter, teríamos que fornecer anotações de tipo para indicar qual implementação de Iterator queremos usar.
Com tipos associados, não precisamos anotar tipos porque não podemos implementar uma trait em um tipo várias vezes. Na Listagem 19-12 com a definição que usa tipos associados, podemos escolher qual será o tipo de Item apenas uma vez porque pode haver apenas um impl Iterator for Counter. Não precisamos especificar que queremos um iterador de valores u32 em todos os lugares que chamamos next em Counter.
Tipos associados também se tornam parte do contrato da trait: os implementadores da trait devem fornecer um tipo para representar o espaço reservado do tipo associado. Tipos associados geralmente têm um nome que descreve como o tipo será usado, e documentar o tipo associado na documentação da API é uma boa prática.
Parâmetros de Tipo Genérico Padrão e Sobrecarga de Operador
Quando usamos parâmetros de tipo genérico, podemos especificar um tipo concreto padrão para o tipo genérico. Isso elimina a necessidade de implementadores da trait especificarem um tipo concreto se o tipo padrão funcionar. Você especifica um tipo padrão ao declarar um tipo genérico com a sintaxe <PlaceholderType=ConcreteType>.
Um ótimo exemplo de uma situação em que essa técnica é útil é com sobrecarga de operador, na qual você personaliza o comportamento de um operador (como +) em situações particulares.
Rust não permite que você crie seus próprios operadores ou sobrecarregue operadores arbitrários. Mas você pode sobrecarregar as operações e as traits correspondentes listadas em std::ops implementando as traits associadas ao operador. Por exemplo, na Listagem 19-14, sobrecarregamos o operador + para somar duas instâncias de Point. Fazemos isso implementando a trait Add em uma struct Point.
Nome do arquivo: src/main.rs
use std::ops::Add;
#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
assert_eq!(
Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 }
);
}
Listagem 19-14: Implementando a trait Add para sobrecarregar o operador + para instâncias de Point
O método add soma os valores x de duas instâncias de Point e os valores y de duas instâncias de Point para criar um novo Point. A trait Add tem um tipo associado chamado Output que determina o tipo retornado do método add.
O tipo genérico padrão neste código está dentro da trait Add. Aqui está sua definição:
trait Add<Rhs=Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
Este código deve parecer geralmente familiar: uma trait com um método e um tipo associado. A parte nova é Rhs=Self: esta sintaxe é chamada de parâmetros de tipo padrão. O parâmetro de tipo genérico Rhs (abreviação de "right-hand side" - lado direito) define o tipo do parâmetro rhs no método add. Se não especificarmos um tipo concreto para Rhs quando implementarmos a trait Add, o tipo de Rhs será definido como Self, que será o tipo em que estamos implementando Add.
Quando implementamos Add para Point, usamos o padrão para Rhs porque queríamos somar duas instâncias de Point. Vamos analisar um exemplo de implementação da trait Add onde queremos personalizar o tipo Rhs em vez de usar o padrão.
Temos duas structs, Millimeters e Meters, que contêm valores em unidades diferentes. Este encapsulamento fino de um tipo existente em outra struct é conhecido como padrão newtype, que descrevemos em mais detalhes em "Usando o Padrão Newtype para Implementar Traits Externas em Tipos Externos". Queremos somar valores em milímetros a valores em metros e fazer com que a implementação de Add faça a conversão corretamente. Podemos implementar Add para Millimeters com Meters como Rhs, conforme mostrado na Listagem 19-15.
Nome do arquivo: src/lib.rs
use std::ops::Add;
struct Millimeters(u32);
struct Meters(u32);
impl Add<Meters> for Millimeters {
type Output = Millimeters;
fn add(self, other: Meters) -> Millimeters {
Millimeters(self.0 + (other.0 * 1000))
}
}
Listagem 19-15: Implementando a trait Add em Millimeters para somar Millimeters e Meters
Para somar Millimeters e Meters, especificamos impl Add<Meters> para definir o valor do parâmetro de tipo Rhs em vez de usar o padrão de Self.
Você usará parâmetros de tipo padrão de duas maneiras principais:
- Para estender um tipo sem quebrar o código existente
- Para permitir a personalização em casos específicos que a maioria dos usuários não precisará
A trait Add da biblioteca padrão é um exemplo do segundo propósito: geralmente, você somará dois tipos semelhantes, mas a trait Add oferece a capacidade de personalizar além disso. Usar um parâmetro de tipo padrão na definição da trait Add significa que você não precisa especificar o parâmetro extra na maioria das vezes. Em outras palavras, um pouco de código boilerplate de implementação não é necessário, tornando mais fácil usar a trait.
O primeiro propósito é semelhante ao segundo, mas ao contrário: se você deseja adicionar um parâmetro de tipo a uma trait existente, pode dar a ele um padrão para permitir a extensão da funcionalidade da trait sem quebrar o código de implementação existente.
Desambiguação entre Métodos com o Mesmo Nome
Nada em Rust impede que uma trait tenha um método com o mesmo nome que o método de outra trait, nem impede que você implemente ambas as traits em um tipo. Também é possível implementar um método diretamente no tipo com o mesmo nome dos métodos das traits.
Ao chamar métodos com o mesmo nome, você precisará dizer ao Rust qual você deseja usar. Considere o código na Listagem 19-16, onde definimos duas traits, Pilot e Wizard, que possuem um método chamado fly. Em seguida, implementamos ambas as traits em um tipo Human que já possui um método chamado fly implementado nele. Cada método fly faz algo diferente.
Nome do arquivo: src/main.rs
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
Listagem 19-16: Duas traits são definidas para ter um método fly e são implementadas no tipo Human, e um método fly é implementado em Human diretamente.
Quando chamamos fly em uma instância de Human, o compilador assume por padrão a chamada do método que é implementado diretamente no tipo, conforme mostrado na Listagem 19-17.
Nome do arquivo: src/main.rs
fn main() {
let person = Human;
person.fly();
}
Listagem 19-17: Chamando fly em uma instância de Human
A execução deste código imprimirá *waving arms furiously*, mostrando que Rust chamou o método fly implementado em Human diretamente.
Para chamar os métodos fly da trait Pilot ou da trait Wizard, precisamos usar uma sintaxe mais explícita para especificar qual método fly queremos. A Listagem 19-18 demonstra essa sintaxe.
Nome do arquivo: src/main.rs
fn main() {
let person = Human;
Pilot::fly(&person);
Wizard::fly(&person);
person.fly();
}
Listagem 19-18: Especificando qual método fly da trait queremos chamar
Especificar o nome da trait antes do nome do método esclarece para o Rust qual implementação de fly queremos chamar. Também poderíamos escrever Human::fly(&person), que é equivalente a person.fly() que usamos na Listagem 19-18, mas isso é um pouco mais longo para escrever se não precisarmos desambiguar.
A execução deste código imprime o seguinte:
This is your captain speaking.
Up!
*waving arms furiously*
Como o método fly recebe um parâmetro self, se tivéssemos dois tipos que implementam uma trait, Rust poderia descobrir qual implementação de uma trait usar com base no tipo de self.
No entanto, funções associadas que não são métodos não possuem um parâmetro self. Quando há vários tipos ou traits que definem funções não-método com o mesmo nome de função, Rust nem sempre sabe qual tipo você quer dizer, a menos que você use a sintaxe totalmente qualificada. Por exemplo, na Listagem 19-19, criamos uma trait para um abrigo de animais que deseja nomear todos os filhotes de cachorro como Spot. Criamos uma trait Animal com uma função associada não-método baby_name. A trait Animal é implementada para a struct Dog, na qual também fornecemos uma função associada não-método baby_name diretamente.
Nome do arquivo: src/main.rs
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", Dog::baby_name());
}
Listagem 19-19: Uma trait com uma função associada e um tipo com uma função associada do mesmo nome que também implementa a trait
Implementamos o código para nomear todos os filhotes de cachorro como Spot na função associada baby_name que é definida em Dog. O tipo Dog também implementa a trait Animal, que descreve características que todos os animais possuem. Filhotes de cachorro são chamados de filhotes, e isso é expresso na implementação da trait Animal em Dog na função baby_name associada à trait Animal.
Em main, chamamos a função Dog::baby_name, que chama a função associada definida em Dog diretamente. Este código imprime o seguinte:
A baby dog is called a Spot
Esta saída não é o que queríamos. Queremos chamar a função baby_name que faz parte da trait Animal que implementamos em Dog para que o código imprima A baby dog is called a puppy. A técnica de especificar o nome da trait que usamos na Listagem 19-18 não ajuda aqui; se mudarmos main para o código na Listagem 19-20, obteremos um erro de compilação.
Nome do arquivo: src/main.rs
fn main() {
println!("A baby dog is called a {}", Animal::baby_name());
}
Listagem 19-20: Tentando chamar a função baby_name da trait Animal, mas Rust não sabe qual implementação usar
Como Animal::baby_name não possui um parâmetro self, e pode haver outros tipos que implementam a trait Animal, Rust não consegue descobrir qual implementação de Animal::baby_name queremos. Obteremos este erro do compilador:
error[E0283]: type annotations needed
--> src/main.rs:20:43
|
20 | println!("A baby dog is called a {}", Animal::baby_name());
| ^^^^^^^^^^^^^^^^^ cannot infer
type
|
= note: cannot satisfy `_: Animal`
Para desambiguar e dizer ao Rust que queremos usar a implementação de Animal para Dog, em vez da implementação de Animal para algum outro tipo, precisamos usar a sintaxe totalmente qualificada. A Listagem 19-21 demonstra como usar a sintaxe totalmente qualificada.
Nome do arquivo: src/main.rs
fn main() {
println!(
"A baby dog is called a {}",
<Dog as Animal>::baby_name()
);
}
Listagem 19-21: Usando a sintaxe totalmente qualificada para especificar que queremos chamar a função baby_name da trait Animal conforme implementada em Dog
Estamos fornecendo ao Rust uma anotação de tipo dentro dos colchetes angulares, o que indica que queremos chamar o método baby_name da trait Animal conforme implementado em Dog, dizendo que queremos tratar o tipo Dog como um Animal para esta chamada de função. Este código agora imprimirá o que queremos:
A baby dog is called a puppy
Em geral, a sintaxe totalmente qualificada é definida da seguinte forma:
<Type as Trait>::function(receiver_if_method, next_arg, ...);
Para funções associadas que não são métodos, não haveria um receiver: haveria apenas a lista de outros argumentos. Você pode usar a sintaxe totalmente qualificada em todos os lugares que você chama funções ou métodos. No entanto, você pode omitir qualquer parte desta sintaxe que o Rust possa descobrir a partir de outras informações no programa. Você só precisa usar esta sintaxe mais verbosa em casos em que há várias implementações que usam o mesmo nome e o Rust precisa de ajuda para identificar qual implementação você deseja chamar.
Usando Supertraits
Às vezes, você pode escrever uma definição de trait que depende de outra trait: para que um tipo implemente a primeira trait, você deseja exigir que esse tipo também implemente a segunda trait. Você faria isso para que sua definição de trait possa usar os itens associados da segunda trait. A trait em que sua definição de trait está se baseando é chamada de supertrait da sua trait.
Por exemplo, digamos que queremos criar uma trait OutlinePrint com um método outline_print que imprimirá um determinado valor formatado para que seja enquadrado em asteriscos. Ou seja, dado uma struct Point que implementa a trait da biblioteca padrão Display para resultar em (x, y), quando chamamos outline_print em uma instância de Point que tem 1 para x e 3 para y, ela deve imprimir o seguinte:
**********
* *
* (1, 3) *
* *
**********
Na implementação do método outline_print, queremos usar a funcionalidade da trait Display. Portanto, precisamos especificar que a trait OutlinePrint funcionará apenas para tipos que também implementam Display e fornecem a funcionalidade que OutlinePrint precisa. Podemos fazer isso na definição da trait especificando OutlinePrint: Display. Essa técnica é semelhante a adicionar uma restrição de trait à trait. A Listagem 19-22 mostra uma implementação da trait OutlinePrint.
Nome do arquivo: src/main.rs
use std::fmt;
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {} *", output);
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
Listagem 19-22: Implementando a trait OutlinePrint que requer a funcionalidade de Display
Como especificamos que OutlinePrint requer a trait Display, podemos usar a função to_string que é implementada automaticamente para qualquer tipo que implemente Display. Se tentássemos usar to_string sem adicionar dois pontos e especificar a trait Display após o nome da trait, obteríamos um erro dizendo que nenhum método chamado to_string foi encontrado para o tipo &Self no escopo atual.
Vamos ver o que acontece quando tentamos implementar OutlinePrint em um tipo que não implementa Display, como a struct Point:
Nome do arquivo: src/main.rs
struct Point {
x: i32,
y: i32,
}
impl OutlinePrint for Point {}
Obtemos um erro dizendo que Display é necessário, mas não implementado:
error[E0277]: `Point` doesn't implement `std::fmt::Display`
--> src/main.rs:20:6
|
20 | impl OutlinePrint for Point {}
| ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Point`
= note: in format strings you may be able to use `{:?}` (or {:#?} for
pretty-print) instead
note: required by a bound in `OutlinePrint`
--> src/main.rs:3:21
|
3 | trait OutlinePrint: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `OutlinePrint`
Para corrigir isso, implementamos Display em Point e satisfazemos a restrição que OutlinePrint requer, assim:
Nome do arquivo: src/main.rs
use std::fmt;
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
Então, implementar a trait OutlinePrint em Point compilará com sucesso, e podemos chamar outline_print em uma instância de Point para exibi-la dentro de um contorno de asteriscos.
Usando o Padrão Newtype para Implementar Traits Externas
Em "Implementando uma Trait em um Tipo", mencionamos a regra do órfão (orphan rule) que afirma que só podemos implementar uma trait em um tipo se a trait ou o tipo, ou ambos, forem locais para o nosso crate. É possível contornar essa restrição usando o padrão newtype, que envolve a criação de um novo tipo em uma struct de tupla. (Cobrimos structs de tupla em "Usando Structs de Tupla Sem Campos Nomeados para Criar Tipos Diferentes".) A struct de tupla terá um campo e será um wrapper fino em torno do tipo para o qual queremos implementar uma trait. Então, o tipo wrapper é local para o nosso crate, e podemos implementar a trait no wrapper. Newtype é um termo que se originou da linguagem de programação Haskell. Não há penalidade de desempenho em tempo de execução para usar este padrão, e o tipo wrapper é elidido em tempo de compilação.
Como exemplo, digamos que queremos implementar Display em Vec<T>, o que a regra do órfão nos impede de fazer diretamente porque a trait Display e o tipo Vec<T> são definidos fora do nosso crate. Podemos criar uma struct Wrapper que contém uma instância de Vec<T>; então podemos implementar Display em Wrapper e usar o valor Vec<T>, conforme mostrado na Listagem 19-23.
Nome do arquivo: src/main.rs
use std::fmt;
struct Wrapper(Vec<String>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Wrapper(vec![
String::from("hello"),
String::from("world"),
]);
println!("w = {w}");
}
Listagem 19-23: Criando um tipo Wrapper em torno de Vec<String> para implementar Display
A implementação de Display usa self.0 para acessar o Vec<T> interno porque Wrapper é uma struct de tupla e Vec<T> é o item no índice 0 na tupla. Então, podemos usar a funcionalidade do tipo Display em Wrapper.
A desvantagem de usar essa técnica é que Wrapper é um novo tipo, então ele não tem os métodos do valor que está contendo. Teríamos que implementar todos os métodos de Vec<T> diretamente em Wrapper de forma que os métodos deleguem para self.0, o que nos permitiria tratar Wrapper exatamente como um Vec<T>. Se quiséssemos que o novo tipo tivesse todos os métodos que o tipo interno tem, implementar a trait Deref em Wrapper para retornar o tipo interno seria uma solução (discutimos a implementação da trait Deref em "Tratando Ponteiros Inteligentes como Referências Regulares com Deref"). Se não quiséssemos que o tipo Wrapper tivesse todos os métodos do tipo interno - por exemplo, para restringir o comportamento do tipo Wrapper - teríamos que implementar apenas os métodos que queremos manualmente.
Este padrão newtype também é útil mesmo quando as traits não estão envolvidas. Vamos mudar o foco e analisar algumas maneiras avançadas de interagir com o sistema de tipos do Rust.
Resumo
Parabéns! Você concluiu o laboratório de Traits Avançadas. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.