Traits: Definindo Comportamento Compartilhado

Beginner

This tutorial is from open-source community. Access the source code

Introdução

Bem-vindo a Traits: Definindo Comportamento Compartilhado. Este laboratório faz parte do Livro Rust. Você pode praticar suas habilidades em Rust no LabEx.

Neste laboratório, exploramos traits como uma forma de definir comportamento compartilhado em um tipo e especificar limites de trait (trait bounds) para tipos genéricos.

Traits: Definindo Comportamento Compartilhado

Uma trait define a funcionalidade que um tipo específico possui e pode compartilhar com outros tipos. Podemos usar traits para definir comportamento compartilhado de forma abstrata. Podemos usar limites de trait (trait bounds) para especificar que um tipo genérico pode ser qualquer tipo que tenha determinado comportamento.

Nota: Traits são semelhantes a um recurso frequentemente chamado de interfaces em outras linguagens, embora com algumas diferenças.

Definindo uma Trait

O comportamento de um tipo consiste nos métodos que podemos chamar nesse tipo. Diferentes tipos compartilham o mesmo comportamento se pudermos chamar os mesmos métodos em todos esses tipos. Definições de trait são uma forma de agrupar assinaturas de métodos para definir um conjunto de comportamentos necessários para realizar algum propósito.

Por exemplo, digamos que temos múltiplas structs que armazenam vários tipos e quantidades de texto: uma struct NewsArticle que armazena uma notícia arquivada em um local específico e um Tweet que pode ter, no máximo, 280 caracteres, juntamente com metadados que indicam se foi um novo tweet, um retweet ou uma resposta a outro tweet.

Queremos criar uma crate de biblioteca agregadora de mídia chamada aggregator que pode exibir resumos de dados que podem ser armazenados em uma instância NewsArticle ou Tweet. Para fazer isso, precisamos de um resumo de cada tipo, e solicitaremos esse resumo chamando um método summarize em uma instância. A Listagem 10-12 mostra a definição de uma trait pública Summary que expressa esse comportamento.

Nome do arquivo: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String;
}

Listagem 10-12: Uma trait Summary que consiste no comportamento fornecido por um método summarize

Aqui, declaramos uma trait usando a palavra-chave trait e, em seguida, o nome da trait, que é Summary neste caso. Também declaramos a trait como pub para que as crates que dependem desta crate também possam usar esta trait, como veremos em alguns exemplos. Dentro das chaves, declaramos as assinaturas dos métodos que descrevem os comportamentos dos tipos que implementam esta trait, que neste caso é fn summarize(&self) -> String.

Após a assinatura do método, em vez de fornecer uma implementação dentro das chaves, usamos um ponto e vírgula. Cada tipo que implementa esta trait deve fornecer seu próprio comportamento personalizado para o corpo do método. O compilador irá garantir que qualquer tipo que tenha a trait Summary terá o método summarize definido exatamente com esta assinatura.

Uma trait pode ter múltiplos métodos em seu corpo: as assinaturas dos métodos são listadas uma por linha, e cada linha termina com um ponto e vírgula.

Implementando uma Trait em um Tipo

Agora que definimos as assinaturas desejadas dos métodos da trait Summary, podemos implementá-la nos tipos em nosso agregador de mídia. A Listagem 10-13 mostra uma implementação da trait Summary na struct NewsArticle que usa o título, o autor e a localização para criar o valor de retorno de summarize. Para a struct Tweet, definimos summarize como o nome de usuário seguido por todo o texto do tweet, assumindo que o conteúdo do tweet já está limitado a 280 caracteres.

Nome do arquivo: src/lib.rs

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!(
            "{}, by {} ({})",
            self.headline,
            self.author,
            self.location
        )
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Listagem 10-13: Implementando a trait Summary nos tipos NewsArticle e Tweet

Implementar uma trait em um tipo é semelhante a implementar métodos regulares. A diferença é que, após impl, colocamos o nome da trait que queremos implementar, então usamos a palavra-chave for e, em seguida, especificamos o nome do tipo para o qual queremos implementar a trait. Dentro do bloco impl, colocamos as assinaturas dos métodos que a definição da trait 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 queremos que os métodos da trait tenham para o tipo específico.

Agora que a biblioteca implementou a trait Summary em NewsArticle e Tweet, os usuários da crate podem chamar os métodos da trait em instâncias de NewsArticle e Tweet da mesma forma que chamamos métodos regulares. A única diferença é que o usuário deve trazer a trait para o escopo, bem como os tipos. Aqui está um exemplo de como uma crate binária pode usar nossa crate de biblioteca aggregator:

use aggregator::{Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

Este código imprime 1 new tweet: horse_ebooks: of course, as you probably already know, people.

Outras crates que dependem da crate aggregator também podem trazer a trait Summary para o escopo para implementar Summary em seus próprios tipos. Uma restrição a ser observada é que podemos implementar uma trait em um tipo somente se a trait ou o tipo, ou ambos, forem locais para nossa crate. Por exemplo, podemos implementar traits da biblioteca padrão como Display em um tipo personalizado como Tweet como parte da funcionalidade de nossa crate aggregator porque o tipo Tweet é local para nossa crate aggregator. Também podemos implementar Summary em Vec<T> em nossa crate aggregator porque a trait Summary é local para nossa crate aggregator.

Mas não podemos implementar traits externas em tipos externos. Por exemplo, não podemos implementar a trait Display em Vec<T> dentro de nossa crate aggregator porque Display e Vec<T> são ambos definidos na biblioteca padrão e não são locais para nossa crate aggregator. Essa restrição faz parte de uma propriedade chamada coerência (coherence), e mais especificamente a regra do órfão (orphan rule), assim chamada porque o tipo pai não está presente. Essa regra garante que o código de outras pessoas não possa quebrar seu código e vice-versa. Sem a regra, duas crates poderiam implementar a mesma trait para o mesmo tipo, e o 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 uma trait, em vez de exigir implementações para todos os métodos em cada tipo. Então, ao implementarmos a trait em um tipo específico, podemos manter ou substituir o comportamento padrão de cada método.

Na Listagem 10-14, especificamos uma string padrão para o método summarize da trait Summary, em vez de apenas definir a assinatura do método, como fizemos na Listagem 10-12.

Nome do arquivo: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Leia mais...)")
    }
}

Listagem 10-14: Definindo uma trait Summary com uma implementação padrão do método summarize

Para usar uma implementação padrão para resumir instâncias de NewsArticle, especificamos um bloco impl vazio com impl Summary for NewsArticle {}.

Embora não estejamos mais definindo o método summarize em NewsArticle diretamente, fornecemos uma implementação padrão e especificamos que NewsArticle implementa a trait Summary. Como resultado, ainda podemos chamar o método summarize em uma instância de NewsArticle, assim:

let article = NewsArticle {
    headline: String::from(
        "Penguins win the Stanley Cup Championship!"
    ),
    location: String::from("Pittsburgh, PA, USA"),
    author: String::from("Iceburgh"),
    content: String::from(
        "The Pittsburgh Penguins once again are the best \
         hockey team in the NHL.",
    ),
};

println!("New article available! {}", article.summarize());

Este código imprime New article available! (Leia mais...).

Criar uma implementação padrão não exige que mudemos nada sobre a implementação de Summary em Tweet na Listagem 10-13. A razão é que a sintaxe para substituir uma implementação padrão é a mesma da sintaxe para implementar um método de trait que não tem uma implementação padrão.

Implementações padrão podem chamar outros métodos na mesma trait, mesmo que esses outros métodos não tenham uma implementação padrão. Dessa forma, uma trait pode fornecer muita funcionalidade útil e exigir que os implementadores especifiquem apenas uma pequena parte dela. Por exemplo, poderíamos definir a trait Summary para ter um método summarize_author cuja implementação é obrigatória e, em seguida, definir um método summarize que tenha uma implementação padrão que chama o método summarize_author:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!(
            "(Leia mais de {}...)",
            self.summarize_author()
        )
    }
}

Para usar esta versão de Summary, só precisamos definir summarize_author quando implementamos a trait em um tipo:

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Depois de definirmos summarize_author, podemos chamar summarize em instâncias da struct Tweet, e a implementação padrão de summarize chamará a definição de summarize_author que fornecemos. Como implementamos summarize_author, a trait Summary nos deu o comportamento do método summarize sem exigir que escrevêssemos mais nenhum código. Veja como isso se parece:

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from(
        "of course, as you probably already know, people",
    ),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summarize());

Este código imprime 1 new tweet: (Leia mais de @horse_ebooks...).

Observe que não é possível chamar a implementação padrão de uma implementação de substituição desse mesmo método.

Traits como Parâmetros

Agora que você sabe como definir e implementar traits, podemos explorar como usar traits para definir funções que aceitam muitos tipos diferentes. Usaremos a trait Summary que implementamos nos tipos NewsArticle e Tweet na Listagem 10-13 para definir uma função notify que chama o método summarize em seu parâmetro item, que é de algum tipo que implementa a trait Summary. Para fazer isso, usamos a sintaxe impl Trait, assim:

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

Em vez de um tipo concreto para o parâmetro item, especificamos a palavra-chave impl e o nome da trait. Este parâmetro aceita qualquer tipo que implemente a trait especificada. No corpo de notify, podemos chamar quaisquer métodos em item que venham da trait Summary, como summarize. Podemos chamar notify e passar qualquer instância de NewsArticle ou Tweet. O código que chama a função com qualquer outro tipo, como uma String ou um i32, não compilará porque esses tipos não implementam Summary.

Sintaxe de Trait Bound

A sintaxe impl Trait funciona para casos simples, mas na verdade é açúcar sintático para uma forma mais longa conhecida como trait bound (limite de trait); ela se parece com isto:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

Esta forma mais longa é equivalente ao exemplo na seção anterior, mas é mais verbosa. Colocamos os limites de trait com a declaração do parâmetro de tipo genérico após dois pontos e dentro de colchetes angulares.

A sintaxe impl Trait é conveniente e torna o código mais conciso em casos simples, enquanto a sintaxe de trait bound mais completa pode expressar mais complexidade em outros casos. Por exemplo, podemos ter dois parâmetros que implementam Summary. Fazer isso com a sintaxe impl Trait se parece com isto:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

Usar impl Trait é apropriado se quisermos que esta função permita que item1 e item2 tenham tipos diferentes (desde que ambos os tipos implementem Summary). Se quisermos forçar ambos os parâmetros a ter o mesmo tipo, no entanto, devemos usar um trait bound, assim:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

O tipo genérico T especificado como o tipo dos parâmetros item1 e item2 restringe a função de forma que o tipo concreto do valor passado como um argumento para item1 e item2 deve ser o mesmo.

Especificando Múltiplos Trait Bounds com a Sintaxe +

Também podemos especificar mais de um trait bound. Digamos que quiséssemos que notify usasse formatação de exibição (display formatting) além de summarize em item: especificamos na definição de notify que item deve implementar tanto Display quanto Summary. Podemos fazer isso usando a sintaxe +:

pub fn notify(item: &(impl Summary + Display)) {

A sintaxe + também é válida com trait bounds em tipos genéricos:

pub fn notify<T: Summary + Display>(item: &T) {

Com os dois trait bounds especificados, o corpo de notify pode chamar summarize e usar {} para formatar item.

Trait Bounds Mais Claras com Cláusulas where

Usar muitos trait bounds tem suas desvantagens. Cada genérico tem seus próprios trait bounds, então funções com múltiplos parâmetros de tipo genérico podem conter muitas informações de trait bound entre o nome da função e sua lista de parâmetros, tornando a assinatura da função difícil de ler. Por esta razão, Rust tem uma sintaxe alternativa para especificar trait bounds dentro de uma cláusula where após a assinatura da função. Então, em vez de escrever isto:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

podemos usar uma cláusula where, assim:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{

A assinatura desta 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 trait bounds.

Retornando Tipos que Implementam Traits

Também podemos usar a sintaxe impl Trait na posição de retorno para retornar um valor de algum tipo que implementa um trait, como mostrado aqui:

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    }
}

Ao usar impl Summary para o tipo de retorno, especificamos que a função returns_summarizable retorna algum tipo que implementa o trait Summary sem nomear o tipo concreto. Neste caso, returns_summarizable retorna um Tweet, mas o código que chama esta função não precisa saber disso.

A capacidade de especificar um tipo de retorno apenas pelo trait que ele implementa é especialmente útil no contexto de closures e iteradores, que abordamos no Capítulo 13. Closures e iteradores criam tipos que apenas o compilador conhece ou tipos que são muito longos para especificar. A sintaxe impl Trait permite que você especifique concisamente que uma função retorna algum tipo que implementa o trait Iterator sem precisar 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 um NewsArticle ou um Tweet com o tipo de retorno especificado como impl Summary não funcionaria:

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            retweet: false,
        }
    }
}

Retornar um NewsArticle ou um Tweet não é permitido devido a restrições em torno de como a sintaxe impl Trait é implementada no compilador. Abordaremos como escrever uma função com este comportamento em "Usando Objetos de Trait que Permitem Valores de Diferentes Tipos".

Usando Trait Bounds para Implementar Métodos Condicionalmente

Ao usar um trait bound com um bloco impl que usa parâmetros de tipo genérico, podemos implementar métodos condicionalmente para tipos que implementam os traits especificados. Por exemplo, o tipo Pair<T> na Listagem 10-15 sempre implementa a função new para retornar uma nova instância de Pair<T> (lembre-se de "Definindo Métodos" que Self é um alias de tipo para o tipo do bloco impl, que neste caso é Pair<T>). Mas no próximo bloco impl, Pair<T> só implementa o método cmp_display se seu tipo interno T implementar o trait PartialOrd que permite a comparação e o trait 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!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

Listagem 10-15: Implementando métodos condicionalmente em um tipo genérico dependendo dos trait bounds

Também podemos implementar condicionalmente um trait para qualquer tipo que implemente outro trait. Implementações de um trait em qualquer tipo que satisfaça os trait bounds são chamadas de implementações blanket e são usadas extensivamente na biblioteca padrão do Rust. Por exemplo, a biblioteca padrão implementa o trait ToString em qualquer tipo que implementa o trait Display. O bloco impl na biblioteca padrão se parece com este código:

impl<T: Display> ToString for T {
    --snip--
}

Como a biblioteca padrão tem esta implementação blanket, podemos chamar o método to_string definido pelo trait ToString em qualquer tipo que implemente o trait Display. Por exemplo, podemos transformar inteiros em seus valores String correspondentes assim, porque os inteiros implementam Display:

let s = 3.to_string();

Implementações blanket aparecem na documentação do trait na seção "Implementadores".

Traits e trait bounds nos permitem escrever código que usa parâmetros de tipo genérico para reduzir a duplicação, mas também especificar ao compilador que queremos que o tipo genérico tenha um comportamento particular. O compilador pode então usar as informações do trait bound para verificar se todos os tipos concretos usados com nosso código fornecem o comportamento correto. Em linguagens de tipagem dinâmica, terí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 Rust move esses erros para o tempo de compilação, então somos forçados a corrigir os problemas antes mesmo que nosso código possa ser executado. Além disso, não precisamos escrever código que verifique o comportamento em tempo de execução porque já verificamos em tempo de compilação. Fazer isso melhora o desempenho sem ter que abrir mão da flexibilidade dos genéricos.

Resumo

Parabéns! Você concluiu o laboratório Traits: Definindo Comportamento Compartilhado. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.