Objetos Trait para Valores Heterogêneos

Beginner

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

Introdução

Bem-vindo(a) a Usando Objetos Trait que Permitem Valores de Diferentes Tipos. Este laboratório faz parte do Livro do Rust. Você pode praticar suas habilidades em Rust no LabEx.

Neste laboratório, exploraremos como usar objetos trait para permitir valores de diferentes tipos em uma biblioteca, especificamente no contexto de uma ferramenta de interface gráfica do usuário (GUI).

Usando Objetos Trait que Permitem Valores de Diferentes Tipos

No Capítulo 8, mencionamos que uma limitação dos vetores é que eles podem armazenar elementos de apenas um tipo. Criamos uma solução alternativa no Listing 8-9, onde definimos um enum SpreadsheetCell que tinha variantes para armazenar inteiros, floats e texto. Isso significava que podíamos armazenar diferentes tipos de dados em cada célula e ainda ter um vetor que representava uma linha de células. Esta é uma solução perfeitamente boa quando nossos itens intercambiáveis são um conjunto fixo de tipos que conhecemos quando nosso código é compilado.

No entanto, às vezes queremos que o usuário da nossa biblioteca possa estender o conjunto de tipos que são válidos em uma situação específica. Para mostrar como podemos conseguir isso, criaremos um exemplo de ferramenta de interface gráfica do usuário (GUI) que itera por uma lista de itens, chamando um método draw em cada um para desenhá-lo na tela - uma técnica comum para ferramentas GUI. Criaremos uma crate de biblioteca chamada gui que contém a estrutura de uma biblioteca GUI. Esta crate pode incluir alguns tipos para as pessoas usarem, como Button ou TextField. Além disso, os usuários de gui desejarão criar seus próprios tipos que podem ser desenhados: por exemplo, um programador pode adicionar uma Image e outro pode adicionar uma SelectBox.

Não implementaremos uma biblioteca GUI completa para este exemplo, mas mostraremos como as peças se encaixariam. No momento da escrita da biblioteca, não podemos saber e definir todos os tipos que outros programadores podem querer criar. Mas sabemos que gui precisa manter o controle de muitos valores de diferentes tipos e precisa chamar um método draw em cada um desses valores de tipos diferentes. Não precisa saber exatamente o que acontecerá quando chamarmos o método draw, apenas que o valor terá esse método disponível para que possamos chamá-lo.

Para fazer isso em uma linguagem com herança, poderíamos definir uma classe chamada Component que tem um método chamado draw. As outras classes, como Button, Image e SelectBox, herdariam de Component e, portanto, herdariam o método draw. Cada uma delas poderia substituir o método draw para definir seu comportamento personalizado, mas o framework poderia tratar todos os tipos como se fossem instâncias de Component e chamar draw neles. Mas como o Rust não tem herança, precisamos de outra maneira de estruturar a biblioteca gui para permitir que os usuários a estendam com novos tipos.

Definindo um Trait para Comportamento Comum

Para implementar o comportamento que queremos que gui tenha, definiremos um trait chamado Draw que terá um método chamado draw. Então, podemos definir um vetor que aceita um objeto trait. Um objeto trait aponta tanto para uma instância de um tipo que implementa nosso trait especificado quanto para uma tabela usada para procurar métodos trait nesse tipo em tempo de execução. Criamos um objeto trait especificando algum tipo de ponteiro, como uma referência & ou um ponteiro inteligente Box<T>, em seguida, a palavra-chave dyn e, em seguida, especificando o trait relevante. (Falaremos sobre a razão pela qual os objetos trait devem usar um ponteiro em "Tipos de Tamanho Dinâmico e o Trait Sized".) Podemos usar objetos trait no lugar de um tipo genérico ou concreto. Onde quer que usemos um objeto trait, o sistema de tipos do Rust garantirá em tempo de compilação que qualquer valor usado nesse contexto implementará o trait do objeto trait. Consequentemente, não precisamos conhecer todos os tipos possíveis em tempo de compilação.

Mencionamos que, no Rust, nos abstemos de chamar structs e enums de "objetos" para distingui-los dos objetos de outras linguagens. Em uma struct ou enum, os dados nos campos da struct e o comportamento nos blocos impl são separados, enquanto em outras linguagens, os dados e o comportamento combinados em um conceito são frequentemente rotulados como um objeto. No entanto, os objetos trait são mais parecidos com objetos em outras linguagens no sentido de que combinam dados e comportamento. Mas os objetos trait diferem dos objetos tradicionais porque não podemos adicionar dados a um objeto trait. Objetos trait não são tão geralmente úteis quanto objetos em outras linguagens: seu propósito específico é permitir a abstração em relação ao comportamento comum.

O Listing 17-3 mostra como definir um trait chamado Draw com um método chamado draw.

Nome do arquivo: src/lib.rs

pub trait Draw {
    fn draw(&self);
}

Listing 17-3: Definição do trait Draw

Esta sintaxe deve parecer familiar de nossas discussões sobre como definir traits no Capítulo 10. Em seguida, vem alguma sintaxe nova: o Listing 17-4 define uma struct chamada Screen que contém um vetor chamado components. Este vetor é do tipo Box<dyn Draw>, que é um objeto trait; é um substituto para qualquer tipo dentro de um Box que implementa o trait Draw.

Nome do arquivo: src/lib.rs

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

Listing 17-4: Definição da struct Screen com um campo components contendo um vetor de objetos trait que implementam o trait Draw

Na struct Screen, definiremos um método chamado run que chamará o método draw em cada um de seus components, conforme mostrado no Listing 17-5.

Nome do arquivo: src/lib.rs

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Listing 17-5: Um método run em Screen que chama o método draw em cada componente

Isso funciona de forma diferente de definir uma struct que usa um parâmetro de tipo genérico com limites de trait. Um parâmetro de tipo genérico só pode ser substituído por um tipo concreto de cada vez, enquanto os objetos trait permitem que vários tipos concretos preencham o objeto trait em tempo de execução. Por exemplo, poderíamos ter definido a struct Screen usando um tipo genérico e um limite de trait, como no Listing 17-6.

Nome do arquivo: src/lib.rs

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Listing 17-6: Uma implementação alternativa da struct Screen e seu método run usando genéricos e limites de trait

Isso nos restringe a uma instância Screen que tem uma lista de componentes, todos do tipo Button ou todos do tipo TextField. Se você só tiver coleções homogêneas, usar genéricos e limites de trait é preferível porque as definições serão monomorfizadas em tempo de compilação para usar os tipos concretos.

Por outro lado, com o método usando objetos trait, uma instância Screen pode conter um Vec<T> que contém um Box<Button>, bem como um Box<TextField>. Vamos ver como isso funciona e, em seguida, falaremos sobre as implicações de desempenho em tempo de execução.

Implementando o Trait

Agora, adicionaremos alguns tipos que implementam o trait Draw. Forneceremos o tipo Button. Novamente, a implementação real de uma biblioteca GUI está além do escopo deste livro, então o método draw não terá nenhuma implementação útil em seu corpo. Para imaginar como a implementação pode ser, uma struct Button pode ter campos para width, height e label, conforme mostrado no Listing 17-7.

Nome do arquivo: src/lib.rs

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}

Listing 17-7: Uma struct Button que implementa o trait Draw

Os campos width, height e label em Button serão diferentes dos campos em outros componentes; por exemplo, um tipo TextField pode ter os mesmos campos mais um campo placeholder. Cada um dos tipos que queremos desenhar na tela implementará o trait Draw, mas usará código diferente no método draw para definir como desenhar esse tipo específico, como Button tem aqui (sem o código GUI real, como mencionado). O tipo Button, por exemplo, pode ter um bloco impl adicional contendo métodos relacionados ao que acontece quando um usuário clica no botão. Esses tipos de métodos não se aplicarão a tipos como TextField.

Se alguém que usa nossa biblioteca decidir implementar uma struct SelectBox que tenha campos width, height e options, eles também implementariam o trait Draw no tipo SelectBox, conforme mostrado no Listing 17-8.

Nome do arquivo: src/main.rs

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

Listing 17-8: Outra crate usando gui e implementando o trait Draw em uma struct SelectBox

O usuário da nossa biblioteca agora pode escrever sua função main para criar uma instância Screen. Para a instância Screen, eles podem adicionar um SelectBox e um Button colocando cada um em um Box<T> para se tornar um objeto trait. Eles podem então chamar o método run na instância Screen, que chamará draw em cada um dos componentes. O Listing 17-9 mostra esta implementação.

Nome do arquivo: src/main.rs

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

Listing 17-9: Usando objetos trait para armazenar valores de diferentes tipos que implementam o mesmo trait

Quando escrevemos a biblioteca, não sabíamos que alguém poderia adicionar o tipo SelectBox, mas nossa implementação Screen foi capaz de operar no novo tipo e desenhá-lo porque SelectBox implementa o trait Draw, o que significa que ele implementa o método draw.

Este conceito - de se preocupar apenas com as mensagens às quais um valor responde, em vez do tipo concreto do valor - é semelhante ao conceito de duck typing em linguagens de tipagem dinâmica: se anda como um pato e grasna como um pato, então deve ser um pato! Na implementação de run em Screen no Listing 17-5, run não precisa saber qual é o tipo concreto de cada componente. Ele não verifica se um componente é uma instância de um Button ou um SelectBox, ele apenas chama o método draw no componente. Ao especificar Box<dyn Draw> como o tipo dos valores no vetor components, definimos que Screen precisa de valores nos quais podemos chamar o método draw.

A vantagem de usar objetos trait e o sistema de tipos do Rust para escrever código semelhante ao código que usa duck typing é que nunca precisamos verificar se um valor implementa um método específico em tempo de execução ou nos preocupar em obter erros se um valor não implementar um método, mas o chamarmos de qualquer maneira. O Rust não compilará nosso código se os valores não implementarem os traits que os objetos trait precisam.

Por exemplo, o Listing 17-10 mostra o que acontece se tentarmos criar um Screen com uma String como um componente.

Nome do arquivo: src/main.rs

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

Listing 17-10: Tentando usar um tipo que não implementa o trait do objeto trait

Obteremos este erro porque String não implementa o trait Draw:

error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is
not implemented for `String`
  |
  = note: required for the cast to the object type `dyn Draw`

Este erro nos informa que estamos passando algo para Screen que não pretendíamos passar e, portanto, devemos passar um tipo diferente, ou devemos implementar Draw em String para que Screen possa chamar draw nele.

Objetos Trait Executam Dispatch Dinâmico

Lembre-se em "Desempenho do Código Usando Genéricos" de nossa discussão sobre o processo de monomorfização realizado pelo compilador quando usamos limites de trait em genéricos: o compilador gera implementações não genéricas de funções e métodos para cada tipo concreto que usamos no lugar de um parâmetro de tipo genérico. O código que resulta da monomorfização está fazendo dispatch estático, que é quando o compilador sabe qual método você está chamando em tempo de compilação. Isso se opõe ao dispatch dinâmico, que é quando o compilador não pode dizer em tempo de compilação qual método você está chamando. Em casos de dispatch dinâmico, o compilador emite código que, em tempo de execução, descobrirá qual método chamar.

Quando usamos objetos trait, o Rust deve usar dispatch dinâmico. O compilador não conhece todos os tipos que podem ser usados com o código que está usando objetos trait, então ele não sabe qual método implementado em qual tipo chamar. Em vez disso, em tempo de execução, o Rust usa os ponteiros dentro do objeto trait para saber qual método chamar. Essa pesquisa incorre em um custo de tempo de execução que não ocorre com o dispatch estático. O dispatch dinâmico também impede que o compilador escolha embutir o código de um método, o que, por sua vez, impede algumas otimizações. No entanto, obtivemos flexibilidade extra no código que escrevemos no Listing 17-5 e fomos capazes de suportar no Listing 17-9, então é uma troca a ser considerada.

Resumo

Parabéns! Você concluiu o laboratório Usando Objetos Trait que Permitem Valores de Diferentes Tipos. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.