Um traço informa ao compilador Rust sobre a funcionalidade que um determinado tipo possui e pode compartilhar com outros tipos. Podemos usar traços para definir o comportamento compartilhado de uma forma abstrata. Podemos usar limites de traços para especificar que um genérico pode ser qualquer tipo que tenha determinado comportamento.
Nota: Traços são semelhantes a um recurso freqüentemente chamado de interfaces em outras linguagens, embora com algumas diferenças.
Definindo um traço
O comportamento de um tipo consiste nos métodos que podemos chamar a esse tipo. Tipos diferentes compartilham o mesmo comportamento se pudermos chamar os mesmos métodos em todos esses tipos. As definições de traços são uma forma de agrupar assinaturas de método para definir um conjunto de comportamentos necessários para realizar algum propósito.
Por exemplo, digamos que temos várias estruturas que contêm vários tipos e quantidades de texto: uma estrutura NovosArtigos
que contém uma notícia arquivada em um local específico e um Tweet
que pode ter no máximo 280 caracteres junto com metadados que indicam se foi um novo tweet, um retuíte ou uma resposta a outro tweet.
Queremos fazer uma biblioteca agregadora de mídia que pode exibir resumos de dados que podem ser armazenados em uma instância NovosArtigos
ou Tweet
. Para fazer isso, precisamos de um resumo de cada tipo e precisamos solicitar esse resumo chamando um método resumir
em uma instância. A Listagem 10-12 mostra a definição de um traço Resumo
que expressa esse comportamento.
Nome do arquivo: src/lib.rs
pub trait Resumo {
fn resumir(&self) -> String;
}
Aqui, declaramos uma traço usando a palavra-chave trait
e depois o nome do traço, que é Resumo
neste caso. Dentro das chaves, declaramos as assinaturas do método que descrevem os comportamentos dos tipos que implementam esse traço, que neste caso é fn resumir(&self) -> String
.
Após a assinatura do método, em vez de fornecer uma implementação entre chaves, usamos um ponto-e-vírgula. Cada tipo que implementa esse traço deve fornecer seu próprio comportamento personalizado para o corpo do método. O compilador fará com que qualquer tipo que tenha o traço Resumo
tenha o método resumir
definido exatamente com esta assinatura.
Um traço pode ter vários métodos em seu corpo: as assinaturas do método são listadas uma por linha e cada linha termina em um ponto-e-vírgula.
Implementando um traço em um tipo
Agora que definimos o comportamento desejado usando o traço Resumo
, podemos implementá-lo nos tipos em nosso agregador de mídia. A Listagem 10-13 mostra uma implementação do traço Resumo
na estrutura NovosArtigos
que usa o título, o autor e o local para criar o valor de retorno resumir
. Para a estrutura Tweet
, definimos resumir
como o nome de usuário seguido por todo o texto do tweet, supondo que o conteúdo do tweet já esteja limitado a 280 caracteres.
Nome do arquivo: src/lib.rs
Implementar uma traço em um tipo é semelhante a implementar métodos regulares. A diferença é que depois de impl
colocamos o nome do traço que queremos implementar, usamos a palavra-chave for
e especificamos o nome do tipo para o qual queremos implementar o traço. Dentro do bloco impl
, colocamos as assinaturas de método que a definição de traço definiu. Em vez de adicionar um ponto-e-vírgula após cada assinatura, usamos chaves e preenchemos o corpo do método com o comportamento específico que desejamos que os métodos do traço tenham para o tipo específico.
Depois de implementar o traço, podemos chamar os métodos em instâncias de NovosArtigos
e Tweet
da mesma forma que chamamos métodos regulares, como este:
Este código é impresso 1 novo tweet: ebooks_de_cavalos: claro, como você provavelmente já sabe, as pessoas
.
Observe que, como definimos o traço Resumo
e os tipos NovosArtigos
e Tweet
no mesmo arquivo lib.rs na Listagem 10-13, eles estão todos no mesmo escopo. Digamos que este arquivo lib.rs seja para um crate que chamamos aggregator
e outra pessoa deseja usar a funcionalidade de nosso crate para implementar o traço Resumo
em uma estrutura definida dentro do escopo de sua biblioteca. Eles precisariam trazer o traço para seu escopo primeiro. Eles fariam isso especificando use aggregator::Resumo;
, o que lhes permitiria implementar Resumo
para seu tipo. O traço Resumo
também precisaria ser um traço público para que outro crate o implementasse, porque colocamos a palavra-chave pub
antes trait
na Listagem 10-12.
Uma restrição a ser observada com as implementações de traços é que podemos implementar um traço em um tipo apenas se o traço ou o tipo for local para nosso crate. Por exemplo, podemos implementar traços de biblioteca padrão como Display
em um tipo personalizado Tweet
como parte de nossa funcionalidade do crate aggregator
, porque o tipo Tweet
é local para nosso crate aggregator
. Nós também podemos implementar Resumo
em Vec<T>
no nosso crate aggregator
, porque o traço Resumo
é local para o nosso crate aggregator
.
Mas não podemos implementar traços externos em tipos externos. Por exemplo, não podemos implementar o traço Display
em nosso Vec<T>
dentro de nosso crate aggregator
, porque Display
e Vec<T>
são definidos na biblioteca padrão e não são locais em nosso crate aggregator
. Essa restrição é parte de uma propriedade dos programas chamada coerência e, mais especificamente, a regra órfã, assim chamada porque o tipo pai não está presente. Esta regra garante que o código de outras pessoas não possa quebrar seu código e vice-versa. Sem a regra, dois crates poderiam implementar o mesmo traço para o mesmo tipo, e Rust não saberia qual implementação usar.
Implementações padrão
Às vezes, é útil ter um comportamento padrão para alguns ou todos os métodos em um traço, em vez de exigir implementações para todos os métodos em cada tipo. Então, conforme implementamos o traço em um tipo específico, podemos manter ou substituir o comportamento padrão de cada método.
A Listagem 10-14 mostra como especificar uma string padrão para o método resumir
do traço Resumo
em vez de apenas definir a assinatura do método, como fizemos na Listagem 10-12.
Nome do arquivo: src/lib.rs
Para usar uma implementação padrão para resumir as instâncias NovosArtigos
em vez de definir uma implementação customizada, especificamos um bloco impl
vazio com impl Resumo for NovosArtigos {}
.
Mesmo que não estejamos mais definindo o método resumir
em NovosArtigos
diretamente, fornecemos uma implementação padrão e especificamos que NovosArtigos
implementa o traço Resumo
. Como resultado, ainda podemos chamar o método resumir
em uma instância de NovosArtigos
, assim:
Este código imprime Novo artigo disponível! (Ler mais...)
.
Criando uma implementação padrão para resumir
não nos obrigam a mudar alguma coisa sobre a implementação de Resumo
em Tweet
na Listagem 10-13. O motivo é que a sintaxe para substituir uma implementação padrão é a mesma que a sintaxe para implementar um método de traço que não tem uma implementação padrão.
Implementações padrão podem chamar outros métodos na mesma traço, mesmo se esses outros métodos não tiverem uma implementação padrão. Dessa forma, uma traço pode fornecer muitas funcionalidades úteis e requer apenas que os implementadores especifiquem uma pequena parte dela. Por exemplo, podemos definir o traço Resumo
para ter um método resumir_autor
cuja implementação é necessária e, em seguida, definir um método resumir
que tem uma implementação padrão que chama o método resumir_autor
:
Para usar esta versão de Resumo
, só precisamos definir resumir_autor
quando implementamos o traço em um tipo:
Depois de definirmos resumir_autor
, podemos chamar a instância resumir
da estrutura Tweet
, e a implementação padrão de resumir
chamará a definição resumir_autor
que fornecemos. Como implementamos resumir_autor
, o traço Resumo
nos deu o comportamento do método resumir
sem exigir que escrevêssemos mais nenhum código.
Este código imprime 1 novo tweet: (Leia mais de @ebooks_de_cavalos...)
.
Observe que não é possível chamar a implementação padrão de uma implementação de substituição do mesmo método.
Traços como parâmetros
Agora que você sabe como definir e implementar características, podemos explorar como usar características para definir funções que aceitam muitos tipos diferentes.
Por exemplo, na Listagem 10-13, implementamos o traço Resumo
nos tipos NovosArtigos
e Tweet
. Podemos definir uma função notificar
que chama o método resumir
em seu parâmetro item
, que é de algum tipo que implementa o traço Resumo
. Para fazer isso, podemos usar a sintaxe impl Trait
, assim:
Em vez de um tipo concreto para o parâmetro item
, especificamos a palavra-chave impl
e o nome do traço. Este parâmetro aceita qualquer tipo que implemente o traço especificado. No corpo de notificar
, podemos chamar qualquer método item
proveniente do traço Resumo
, como resumir
. Podemos chamar notificar
e passar em qualquer instância de NovosArtigos
ou Tweet
. O código que chama a função com qualquer outro tipo, como a String
ou um i32
, não será compilado porque esses tipos não implementam Resumo
.
Sintaxe de limite de traço
A sintaxe impl Trait
funciona para casos simples, mas, na verdade, é um açúcar de sintaxe para uma forma mais longa, que é chamada de limite de traço; Se parece com isso:
pub fn notificar<T: Resumo>(item: &T) {
println!("Últimas notícias! {}", item.resumir());
}
Esta forma mais longa é equivalente ao exemplo da seção anterior, mas é mais detalhada. Colocamos limites de traço com a declaração do parâmetro de tipo genérico depois de dois pontos e entre colchetes angulares.
A sintaxe impl Trait
é conveniente e torna o código mais conciso em casos simples. A sintaxe associada ao traço pode expressar mais complexidade em outros casos. Por exemplo, podemos ter dois parâmetros que implementam Resumo
. O uso da sintaxe impl Trait
é parecido com o seguinte:
pub fn notificar(item1: &impl Resumo, item2: &impl Resumo) {
Se quiséssemos que essa função permitisse que item1
e item2
tivesse tipos diferentes, o uso de impl Trait
seria apropriado (desde que ambos os tipos implementem Resumo
). Se quisermos forçar os dois parâmetros a terem o mesmo tipo, isso só é possível expressar usando um limite de traço, como este:
pub fn notificar<T: Resumo>(item1: &T, item2: &T) {
O tipo genérico T
especificado como o tipo dos parâmetros item1
e item2
restringe a função de modo que o tipo concreto do valor passado como um argumento para item1
e item2
deve ser o mesmo.
Especificando vários limites de traços com a sintaxe +
Também podemos especificar mais de um limite de traço. Digamos que quiséssemos usar notificar
para formatação de exibição de item
, bem como o método resumir
: especificamos na definição de notificar
que item
deve implementar Display
e Resumo
. Podemos fazer isso usando a sintaxe +
:
pub fn notificar(item: &(impl Resumo + Display)) {
A sintaxe +
também é válida com limites de traço em tipos genéricos:
pub fn notificar<T: Resumo + Display>(item: &T) {
Com os dois limites de traço especificados, o corpo de notificar
pode chamar resumir
e usar {}
para formatar item
.
Limites de traço mais claros com a cláusulas where
Usar muitos limites de características tem suas desvantagens. Cada genérico tem seus próprios limites de traço, portanto funções com vários parâmetros de tipo genérico podem conter muitas informações de limite de traço entre o nome da função e sua lista de parâmetros, tornando a assinatura da função difícil de ler. Por esse motivo, Rust tem uma sintaxe alternativa para especificar limites de características dentro de uma cláusula where
após a assinatura da função. Então, em vez de escrever isso:
fn alguma_funcao<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
podemos usar uma cláusula where
, como esta:
fn alguma_funcao<T, U>(t: &T, u: &U) -> i32
where T: Display + Clone,
U: Clone + Debug
{
A assinatura dessa função é menos confusa: o nome da função, a lista de parâmetros e o tipo de retorno estão próximos, semelhante a uma função sem muitos limites de características.
Retornando Tipos que Implementam Traços
Também podemos usar a sintaxe impl Trait
na posição de retorno para retornar um valor de algum tipo que implementa uma traço, conforme mostrado aqui:
Ao usar impl Resumo
para o tipo de retorno, especificamos que a função retorna_resumivel
retorna algum tipo que implementa o traço Resumo
sem nomear o tipo concreto. Nesse caso, retorna_resumivel
retorna um Tweet
, mas o código que chama esta função não sabe disso.
A capacidade de retornar um tipo que é especificado apenas pela traço que ele implementa é especialmente útil no contexto de fechamentos e iteradores, que abordamos no Capítulo 13. Fechamentos e iteradores criam tipos que apenas o compilador conhece ou tipos que são muito longos para especificamos. A impl Trait
sintaxe permite que você especifique concisamente que uma função retorna algum tipo que implementa a Iterator
traço sem a necessidade de escrever um tipo muito longo.
No entanto, você só pode usar impl Trait
se estiver retornando um único tipo. Por exemplo, este código que retorna a NovosArtigos
ou a Tweet
com o tipo de retorno especificado como impl Resumo
não funcionaria:
Esse código não compila.
Retornar um NovosArtigos
ou um Tweet
não é permitido devido a restrições sobre como a sintaxe impl Trait
é implementada no compilador. Abordaremos como escrever uma função com esse comportamento na seção “Usando objetos de traço que permitem valores de tipos diferentes” do Capítulo 17.
Corrigindo a função maior
com limites de traço
Agora que você sabe como especificar o comportamento que deseja usar usando os limites do parâmetro de tipo genérico, vamos retornar à Listagem 10-5 para corrigir a definição da função maior
que usa um parâmetro de tipo genérico! Da última vez que tentamos executar esse código, recebemos este erro:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:17
|
5 | if item > maior {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T`
|
1 | fn maior<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ^^^^^^^^^^^^^^^^^^^^^^
error: aborting due to previous error
For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10`
To learn more, run the command again with --verbose.
No corpo da função maior
, queríamos comparar dois valores de tipo T
usando o operador maior que (>
). Como esse operador é definido como um método padrão no traço da biblioteca padrão std::cmp::PartialOrd
, precisamos especificar PartialOrd
nos limites do traço para T
que a função maior
possa funcionar em fatias de qualquer tipo que possamos comparar. Não precisamos trazer PartialOrd
para o escopo porque está no prelúdio. Altere a assinatura de maior
para ficar assim:
Desta vez, quando compilamos o código, obtemos um conjunto diferente de erros:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0508]: cannot move out of type `[T]`, a non-copy slice
--> src/main.rs:2:23
|
2 | let mut maior = list[0];
| ^^^^^^^
| |
| cannot move out of here
| move occurs because `list[_]` has type `T`, which does not implement the `Copy` trait
| help: consider borrowing here: `&list[0]`
error[E0507]: cannot move out of a shared reference
--> src/main.rs:4:18
|
4 | for &item in list {
| ----- ^^^^
| ||
| |data moved here
| |move occurs because `item` has type `T`, which does not implement the `Copy` trait
| help: consider removing the `&`: `item`
error: aborting due to 2 previous errors
Some errors have detailed explanations: E0507, E0508.
For more information about an error, try `rustc --explain E0507`.
error: could not compile `chapter10`
To learn more, run the command again with --verbose.
A linha chave neste erro é cannot move out of type [T], a non-copy slice
. Com nossas versões não genéricas da função maior
, estávamos apenas tentando encontrar o maior i32
ou char
. Conforme discutido na seção “Dados somente de pilha: cópia” no Capítulo 4, tipos como i32
e char
que têm um tamanho conhecido podem ser armazenados na pilha, para que implementem o traço Copy
. Mas quando tornamos a função maior
genérica, tornou-se possível para o parâmetro list
ter tipos que não implementam o traço Copy
. Consequentemente, não poderíamos mover o valor para fora de list[0]
e para dentro da variável maior
, resultando neste erro.
Para chamar esse código apenas com os tipos que implementam o traço Copy
, podemos adicionar Copy
aos limites do traço T
! A Listagem 10-15 mostra o código completo de uma função maior
genérica que será compilada, desde que os tipos dos valores na fatia que passamos para a função implementem os traços PartialOrd
e Copy
, como i32
e char
.
Nome do arquivo: src/main.rs
fn maior<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut maior = list[0];
for &item in list {
if item > maior {
maior = item;
}
}
maior
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = maior(&number_list);
println!("O maior número é {}", result);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = maior(&char_list);
println!("O maior char é {}", result);
}
Se não quisermos restringir a função maior
aos tipos que implementam o traço Copy
, poderíamos especificar que T
tem o traço limitada em Clone
em vez de Copy
. Então, poderíamos clonar cada valor na fatia quando quisermos que a função maior
tenha propriedade. Usar a função clone
significa que estamos potencialmente fazendo mais alocações de heap no caso de tipos que possuem dados de heap String
, e as alocações de heap podem ser lentas se estivermos trabalhando com grandes quantidades de dados.
Outra maneira que podemos implementar maior
é a função retornar uma referência a um valor T
na fatia. Se alterarmos o tipo de retorno para &T
em vez de T
, alterando assim o corpo da função para retornar uma referência, não precisaríamos dos limites de traço Clone
ou Copy
e poderíamos evitar alocações de heap. Tente implementar essas soluções alternativas por conta própria!
Usando Limites de Traço para Implementar Métodos Condicionalmente
Usando um traço vinculado a um bloco impl
que usa parâmetros de tipo genérico, podemos implementar métodos condicionalmente para tipos que implementam os traços especificados. Por exemplo, o tipo Pair<T>
na Listagem 10-16 sempre implementa a função new
. Mas Pair<T>
apenas implementa o método cmp_display
se seu tipo interno T
implementar o traço PartialOrd
que permite a comparação e o traço Display
que permite a impressão.
Nome do arquivo: src/lib.rs
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("O maior membro é x = {}", self.x);
} else {
println!("O maior membro é y = {}", self.y);
}
}
}
Também podemos implementar condicionalmente um traço para qualquer tipo que implemente outro traço. As implementações de uma traço em qualquer tipo que satisfaça os limites do traço são chamadas de implementações de cobertura e são amplamente utilizadas na biblioteca padrão do Rust. Por exemplo, a biblioteca padrão implementa o traço ToString
em qualquer tipo que implemente o traço Display
. O bloco impl
na biblioteca padrão é semelhante a este código:
impl<T: Display> ToString for T {
// --recorte--
}
Como a biblioteca padrão tem essa implementação abrangente, podemos chamar o método to_string
definido pelo traço ToString
em qualquer tipo que implemente o traço Display
. Por exemplo, podemos transformar inteiros em seus valores String
correspondentes assim porque os inteiros implementam Display
:
Implementações de cobertura aparecem na documentação para a traço na seção “Implementadores”.
Traços e limites de traços nos permitem escrever código que usa parâmetros de tipo genérico para reduzir a duplicação, mas também especificar para o compilador que queremos que o tipo genérico tenha um comportamento particular. O compilador pode então usar as informações de limite de traço para verificar se todos os tipos concretos usados com nosso código fornecem o comportamento correto. Em linguagens tipadas dinamicamente, obteríamos um erro em tempo de execução se chamássemos um método em um tipo que não definisse o método. Mas o Rust move esses erros para o tempo de compilação, então somos forçados a consertar os problemas antes mesmo de nosso código ser executado. Além disso, não precisamos escrever código que verifique o comportamento em tempo de execução porque já o verificamos em tempo de compilação. Isso melhora o desempenho sem ter que abrir mão da flexibilidade dos genéricos.
Outro tipo de genérico que já usamos é chamado de vidas úteis. Em vez de garantir que um tipo tenha o comportamento que desejamos, os tempos de vida garantem que as referências sejam válidas pelo tempo que precisarmos. Vejamos como as vidas fazem isso.
Traduzido por Acervo Lima. O original pode ser acessado aqui.
Top demais, está me ajudando a entender melhor todos esses conceitos
ResponderExcluir