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.
pub trait Draw {
fn draw(&self);
}
DrawEssa 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.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen com um campo components que guarda um vetor de objetos trait que implementam a trait DrawNa 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.
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();
}
}
}
run em Screen que chama o método draw em cada componenteIsso 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.
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();
}
}
}
Screen e de seu método run usando genéricos e trait boundsIsso 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.
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
}
}
Button que implementa a trait DrawOs 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.
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() {}
gui e implementando a trait Draw em uma struct SelectBoxO 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.
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();
}
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.
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
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.