Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Usando Objetos Trait Para Abstrair Comportamento Compartilhado

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 na Listagem 8-9, em que definimos um enum SpreadsheetCell com variantes para armazenar inteiros, floats e texto. Isso significava que poderíamos armazenar diferentes tipos de dados em cada célula e ainda ter um vetor que representava uma linha de células. Essa é 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 consiga estender o conjunto de tipos válidos em uma situação específica. Para mostrar como poderíamos fazer 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 em ferramentas GUI. Criaremos um crate de biblioteca chamado gui, que contém a estrutura de uma biblioteca GUI. Esse crate poderia incluir alguns tipos para as pessoas usarem, como Button ou TextField. Além disso, usuários de gui vão querer criar seus próprios tipos que possam ser desenhados: por exemplo, uma pessoa programadora poderia adicionar um Image, e outra poderia adicionar um SelectBox.

No momento em que escrevemos a biblioteca, não podemos conhecer e definir todos os tipos que outras pessoas programadoras talvez queiram criar. Mas sabemos que gui precisa acompanhar muitos valores de tipos diferentes e precisa chamar um método draw em cada um desses valores de tipos diferentes. Ela 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 chamarmos.

Para fazer isso em uma linguagem com herança, poderíamos definir uma classe chamada Component com 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 poderia sobrescrever 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 Rust não tem herança, precisamos de outra forma de estruturar a biblioteca gui para permitir que usuários criem novos tipos compatíveis com a biblioteca.

Definindo uma Trait Para Comportamento Comum

Para implementar o comportamento que queremos que gui tenha, definiremos uma trait chamada Draw com um método chamado draw. Então podemos definir um vetor que recebe um objeto trait. Um objeto trait aponta tanto para uma instância de um tipo que implementa a trait especificada quanto para uma tabela usada para procurar métodos dessa trait nesse tipo em tempo de execução. Criamos um objeto trait especificando algum tipo de ponteiro, como uma referência ou um smart pointer Box<T>, depois a palavra-chave dyn e então a trait relevante. (Falaremos sobre o motivo pelo qual objetos trait precisam usar um ponteiro em “Tipos de Tamanho Dinâmico e a Trait Sized no Capítulo 20.) Podemos usar objetos trait no lugar de um tipo genérico ou concreto. Onde quer que usemos um objeto trait, o sistema de tipos de Rust garantirá em tempo de compilação que qualquer valor usado nesse contexto implementará a trait do objeto trait. Consequentemente, não precisamos conhecer todos os tipos possíveis em tempo de compilação.

Mencionamos que, em Rust, evitamos 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 ficam separados, enquanto em outras linguagens a combinação de dados e comportamento em um único conceito costuma receber o rótulo de objeto. Objetos trait diferem de objetos em outras linguagens 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 abstração sobre comportamento comum.

A Listagem 18-3 mostra como definir uma trait chamada Draw com um método chamado draw.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}
Listing 18-3: Definição da trait Draw

Essa sintaxe deve parecer familiar a partir das nossas discussões sobre como definir traits no Capítulo 10. Em seguida vem uma sintaxe nova: a Listagem 18-4 define uma struct chamada Screen que guarda um vetor chamado components. Esse vetor é do tipo Box<dyn Draw>, que é um objeto trait; ele substitui qualquer tipo dentro de um Box que implemente a trait Draw.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
Listing 18-4: Definição da struct Screen com um campo components que guarda um vetor de objetos trait que implementam a trait Draw

Na struct Screen, definiremos um método chamado run que chamará o método draw em cada um de seus components, como mostrado na Listagem 18-5.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

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

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
Listing 18-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 trait bounds. Um parâmetro de tipo genérico só pode ser substituído por um tipo concreto por vez, enquanto objetos trait permitem que múltiplos tipos concretos ocupem o lugar do objeto trait em tempo de execução. Por exemplo, poderíamos ter definido a struct Screen usando um tipo genérico e um trait bound, como na Listagem 18-6.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

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 18-6: Uma implementação alternativa da struct Screen e de seu método run usando genéricos e trait bounds

Isso nos restringe a uma instância de Screen que tenha uma lista de componentes todos do tipo Button ou todos do tipo TextField. Se você sempre tiver coleções homogêneas, usar genéricos e trait bounds é 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 que usa objetos trait, uma instância de Screen pode guardar um Vec<T> que contém um Box<Button> e também um Box<TextField>. Vamos ver como isso funciona e depois falaremos sobre as implicações de desempenho em tempo de execução.

Implementando a Trait

Agora adicionaremos alguns tipos que implementam a trait Draw. Forneceremos o tipo Button. Novamente, implementar de fato 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 seria a implementação, uma struct Button poderia ter campos para width, height e label, como mostrado na Listagem 18-7.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

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

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

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 18-7: Uma struct Button que implementa a trait Draw

Os campos width, height e label em Button serão diferentes dos campos em outros componentes; por exemplo, um tipo TextField poderia ter esses mesmos campos mais um campo placeholder. Cada um dos tipos que queremos desenhar na tela implementará a trait Draw, mas usará código diferente no método draw para definir como desenhar aquele tipo específico, como Button fez aqui (sem o código GUI real, como mencionado). O tipo Button, por exemplo, poderia 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 usando nossa biblioteca decidir implementar uma struct SelectBox com campos width, height e options, também implementará a trait Draw no tipo SelectBox, como mostrado na Listagem 18-8.

Filename: 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
    }
}

fn main() {}
Listing 18-8: Outro crate usando gui e implementando a trait Draw em uma struct SelectBox

O usuário da nossa biblioteca agora pode escrever sua função main para criar uma instância de Screen. Na instância de Screen, ele pode adicionar um SelectBox e um Button, colocando cada um em um Box<T> para se tornar um objeto trait. Ele pode então chamar o método run na instância de Screen, que chamará draw em cada um dos componentes. A Listagem 18-9 mostra essa implementação.

Filename: 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
    }
}

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 18-9: Usando objetos trait para armazenar valores de tipos diferentes que implementam a mesma trait

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

Esse 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 faz quack como um pato, então deve ser um pato! Na implementação de run em Screen na Listagem 18-5, run não precisa saber qual é o tipo concreto de cada componente. Ele não verifica se um componente é uma instância de Button ou SelectBox; 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 possamos chamar o método draw.

A vantagem de usar objetos trait e o sistema de tipos de Rust para escrever código semelhante a código que usa duck typing é que nunca precisamos verificar se um valor implementa um método específico em tempo de execução nem nos preocupar com erros caso um valor não implemente um método mas o chamemos mesmo assim. Rust não compilará nosso código se os valores não implementarem as traits exigidas pelos objetos trait.

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

Filename: src/main.rs
use gui::Screen;

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

    screen.run();
}
Listing 18-10: Tentando usar um tipo que não implementa a trait exigida pelo objeto trait

Receberemos este erro porque String não implementa a trait Draw:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
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`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error

Esse erro nos informa que estamos passando algo para Screen que não pretendíamos passar e, portanto, devemos passar um tipo diferente, ou então devemos implementar Draw para String para que Screen consiga chamar draw nela.

Realizando Despacho Dinâmico

Lembre-se da discussão sobre o processo de monomorfização realizado pelo compilador em genéricos na seção “Desempenho de Código Usando Genéricos” do Capítulo 10: 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 resultante da monomorfização faz static dispatch, que é quando o compilador sabe em tempo de compilação qual método você está chamando. Isso se opõe ao dynamic dispatch, que é quando o compilador não consegue dizer em tempo de compilação qual método você está chamando. Em casos de despacho dinâmico, o compilador emite código que saberá em tempo de execução qual método chamar.

Quando usamos objetos trait, Rust precisa usar despacho dinâmico. O compilador não conhece todos os tipos que podem ser usados com o código que usa objetos trait, então não sabe qual método implementado em qual tipo deve chamar. Em vez disso, em tempo de execução, Rust usa os ponteiros dentro do objeto trait para saber qual método chamar. Essa busca incorre em um custo em tempo de execução que não ocorre com despacho estático. O despacho dinâmico também impede que o compilador escolha fazer inline do código de um método, o que por sua vez impede algumas otimizações, e Rust tem algumas regras sobre onde você pode e não pode usar despacho dinâmico, chamadas compatibilidade dyn. Essas regras estão além do escopo desta discussão, mas você pode ler mais sobre elas na referência. No entanto, obtivemos flexibilidade extra no código que escrevemos na Listagem 18-5 e conseguimos dar suporte ao código da Listagem 18-9, então esse é um trade-off a considerar.