Pular para o conteúdo

Trait objects em Rust (dyn)

Na página de Traits você escreveu funções que aceitam “qualquer tipo que implemente Speak”. Na página de Genéricos você viu que o Rust cria uma cópia separada de uma função genérica para cada tipo. As duas coisas funcionam muito bem quando você já sabe o tipo na hora de compilar. Mas e se você quiser uma única lista com vários tipos diferentes?

É para isso que existe o trait object (objeto de trait). Pense em um professor de música com uma sala cheia de alunos. Um toca piano, outro toca violino, outro toca bateria. O professor não precisa saber qual instrumento cada aluno tem: ele só diz “toquem!” e cada um toca do seu jeito. Um trait object deixa o seu código fazer a mesma coisa: “não sei o tipo exato, mas sei que ele consegue fazer isto”.

Digamos que você tem duas formas e quer guardar as duas no mesmo Vec:

struct Circle { radius: f64 }
struct Square { side: f64 }
fn main() {
let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];
}

Isso não compila:

error[E0308]: mismatched types
--> src/main.rs:5:47
|
5 | let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];
| ^^^^^^^^^^^^^^^^^^^^ expected `Circle`, found `Square`

O compilador diz que os tipos não batem: ele esperava um Circle e encontrou um Square. Um Vec só guarda um tipo. Todos os itens precisam ter o mesmo tamanho na memória, para que o Rust consiga enfileirá-los um atrás do outro. Circle e Square são tipos diferentes, então não podem dividir um Vec comum.

A solução tem duas partes. Primeiro, dê às duas formas uma trait em comum. Depois, coloque cada forma dentro de um Box e diga que o tipo é Box<dyn Shape>:

trait Shape {
fn name(&self) -> String;
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn name(&self) -> String { String::from("círculo") }
fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
}
impl Shape for Square {
fn name(&self) -> String { String::from("quadrado") }
fn area(&self) -> f64 { self.side * self.side }
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 2.0 }),
Box::new(Square { side: 3.0 }),
Box::new(Circle { radius: 1.0 }),
];
for shape in &shapes {
println!("{} tem área {:.2}", shape.name(), shape.area());
}
}

Saída:

círculo tem área 12.57
quadrado tem área 9.00
círculo tem área 3.14
▶ Experimente você mesmo

Vamos ler Box<dyn Shape> parte por parte:

  • dyn Shape quer dizer “algum tipo que implementa Shape; vamos descobrir qual quando o programa estiver rodando”. dyn é a abreviação de dynamic (dinâmico).
  • Box coloca o valor na heap e guarda um ponteiro para ele (você conheceu o Box na página de Ponteiros inteligentes). Todo Box tem o mesmo tamanho, não importa o que está dentro. É por isso que agora o Vec fica feliz.

O valor dentro da caixa (mais um ponteiro escondido que diz “este aqui é um Circle”) é o que chamamos de trait object.

Nem sempre você precisa de um Box. Se os valores já vivem em outro lugar, uma referência também serve. &dyn Shape quer dizer “uma referência emprestada para algum tipo que implementa Shape”:

trait Shape {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
}
impl Shape for Square {
fn area(&self) -> f64 { self.side * self.side }
}
fn print_area(shape: &dyn Shape) {
println!("Área: {:.1}", shape.area());
}
fn main() {
let circle = Circle { radius: 1.0 };
let square = Square { side: 4.0 };
print_area(&circle);
print_area(&square);
let both: Vec<&dyn Shape> = vec![&circle, &square];
println!("Temos {} formas", both.len());
}

Saída:

Área: 3.1
Área: 16.0
Temos 2 formas
▶ Experimente você mesmo

Uma regra simples: use Box<dyn Trait> quando a lista deve ser dona dos valores e &dyn Trait quando você só precisa pegá-los emprestado por um tempo.

“Dispatch” quer dizer apenas “decidir qual método chamar”. O Rust pode fazer isso de dois jeitos.

O dispatch estático acontece com genéricos e impl Trait. O compilador sabe o tipo exato, então cria uma cópia especial da função para cada tipo (como você viu na página de Genéricos). A chamada vai direto para o método certo. É muito rápido.

O dispatch dinâmico acontece com dyn Trait. O compilador não sabe o tipo exato. Por isso, cada trait object carrega uma pequena tabela escondida (chamada vtable) que diz “para este valor, o area fica aqui”. Em tempo de execução, o Rust procura o método nessa tabela e depois o chama. Essa consulta tem um custo extra bem pequeno.

// Dispatch estático: o Rust cria uma cópia desta função para cada tipo
fn static_area(shape: &impl Shape) -> f64 {
shape.area()
}
// Dispatch dinâmico: uma só função; o método é procurado em tempo de execução
fn dynamic_area(shape: &dyn Shape) -> f64 {
shape.area()
}

As duas funções dão a mesma resposta para a mesma forma. A única diferença é como o Rust encontra o método area certo. Então, qual escolher?

Use genéricos / impl Trait quando… Use dyn Trait quando…
Cada chamada trabalha com um tipo conhecido Você precisa misturar tipos diferentes na mesma coleção
Você quer o código mais rápido possível Você decide o tipo em tempo de execução (por exemplo, pelo que o usuário digitou)
Um programa um pouco maior não incomoda Você quer código menor e compilação mais rápida

Um bom ponto de partida para quem está começando: comece com genéricos ou impl Trait. Use dyn quando bater no problema de “tipos diferentes na mesma lista”. Não se preocupe muito com a diferença de velocidade: na maioria dos programas você nunca vai notar.

Às vezes uma função precisa decidir em tempo de execução qual tipo retornar. Você pode tentar -> impl Shape, mas isso só funciona quando a função sempre retorna o mesmo tipo. Se cada ramo retorna um tipo diferente, retorne um Box<dyn Shape>:

trait Shape {
fn describe(&self) -> String;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn describe(&self) -> String { format!("um círculo de raio {}", self.radius) }
}
impl Shape for Square {
fn describe(&self) -> String { format!("um quadrado de lado {}", self.side) }
}
fn make_shape(kind: &str, size: f64) -> Box<dyn Shape> {
if kind == "circle" {
Box::new(Circle { radius: size })
} else {
Box::new(Square { side: size })
}
}
fn main() {
let a = make_shape("circle", 1.5);
let b = make_shape("square", 2.0);
println!("Eu criei {}", a.describe());
println!("Eu criei {}", b.describe());
}

Saída:

Eu criei um círculo de raio 1.5
Eu criei um quadrado de lado 2
▶ Experimente você mesmo

Esse é um padrão comum em funções “fábrica” (factory), sistemas de plugins e código guiado por configuração: qualquer lugar onde o tipo exato depende de dados que você só vê com o programa rodando.

Aqui vai algo mais próximo de um app de verdade. Seu programa precisa avisar um usuário, mas cada usuário escolheu canais diferentes: e-mail, SMS ou os dois. Cada canal é um tipo próprio, e todos compartilham a trait Notifier:

trait Notifier {
fn send(&self, message: &str) -> String;
}
struct Email { address: String }
struct Sms { phone: String }
impl Notifier for Email {
fn send(&self, message: &str) -> String {
format!("E-mail para {}: {}", self.address, message)
}
}
impl Notifier for Sms {
fn send(&self, message: &str) -> String {
format!("SMS para {}: {}", self.phone, message)
}
}
fn notify_all(channels: &[Box<dyn Notifier>], message: &str) {
for channel in channels {
println!("{}", channel.send(message));
}
}
fn main() {
let channels: Vec<Box<dyn Notifier>> = vec![
Box::new(Email { address: String::from("[email protected]") }),
Box::new(Sms { phone: String::from("+55-11-5555-0100") }),
];
notify_all(&channels, "Seu pedido foi enviado!");
}

Saída:

E-mail para [email protected]: Seu pedido foi enviado!
SMS para +55-11-5555-0100: Seu pedido foi enviado!
▶ Experimente você mesmo

A parte boa: se mais tarde você quiser um notificador Push, basta escrever uma struct nova e um impl Notifier for Push. A função notify_all não muda nada.

Nem toda trait pode virar um trait object. A trait precisa ser dyn compatible (compatível com dyn; livros e posts mais antigos chamam isso de object safety). As regras completas são longas, mas duas cobrem a maioria dos casos reais:

  • Um método não pode retornar Self. (Atrás de um dyn, o Rust não sabe o que é Self, então não tem como saber o tamanho do valor de retorno.)
  • Um método não pode ter seus próprios parâmetros de tipo genéricos, como fn process<T>(&self, item: T).

Veja o que acontece se você quebrar a primeira regra:

trait Shape {
fn area(&self) -> f64;
fn duplicate(&self) -> Self;
}
// ... mais adiante:
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];
error[E0038]: the trait `Shape` is not dyn compatible
|
14 | let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];
| ^^^^^ `Shape` is not dyn compatible
|
1 | trait Shape {
| ----- this trait is not dyn compatible...
2 | fn area(&self) -> f64;
3 | fn duplicate(&self) -> Self;
| ^^^^ ...because method `duplicate` references the `Self` type in its return type
= help: consider moving `duplicate` to another trait

O erro é honesto e ajuda de verdade: ele diz que a trait não é compatível com dyn porque o método duplicate usa Self no tipo de retorno, e sugere mover esse método para outra trait. Suas opções são justamente essa, ou adicionar where Self: Sized ao método. Isso diz ao Rust “este método só existe em tipos reais e conhecidos, não em trait objects”:

trait Shape {
fn area(&self) -> f64;
fn duplicate(&self) -> Self where Self: Sized;
}

É também por isso que algumas traits da biblioteca padrão, como Clone, não podem ser usadas como dyn Clone: clone() retorna Self.

Esquecer o Box. Dá vontade de escrever Vec<dyn Shape>:

let shapes: Vec<dyn Shape> = Vec::new();
error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time
--> src/main.rs:3:17
|
3 | let shapes: Vec<dyn Shape> = Vec::new();
| ^^^^^^^^^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `dyn Shape`

O compilador reclama que não dá para saber o tamanho de um dyn Shape em tempo de compilação. Faz sentido: um dyn Shape pode ter qualquer tamanho, desde um Square pequeno até uma struct grande com vários campos. O Rust precisa de um tamanho fixo, então um trait object sempre tem que ficar atrás de um ponteiro: Box<dyn Shape>, &dyn Shape ou Rc<dyn Shape>.

Usar dyn em todo lugar. Se uma função só recebe um tipo de cada vez, impl Trait ou genéricos são mais simples e mais rápidos (a página de Traits avançados fala mais sobre impl Trait). Guarde o dyn para quando você realmente precisar misturar tipos.

Defina uma trait Animal com fn name(&self) -> String e fn sound(&self) -> String. Implemente-a para Dog, Cow e Duck, coloque um de cada em um Vec<Box<dyn Animal>> e percorra o vetor imprimindo o som que cada animal faz. Dica: as structs podem ser vazias (struct Dog;), e cada uma entra no vetor com Box::new(...).

Ver solução
trait Animal {
fn name(&self) -> String;
fn sound(&self) -> String;
}
struct Dog;
struct Cow;
struct Duck;
impl Animal for Dog {
fn name(&self) -> String { String::from("O cachorro") }
fn sound(&self) -> String { String::from("au au") }
}
impl Animal for Cow {
fn name(&self) -> String { String::from("A vaca") }
fn sound(&self) -> String { String::from("muu") }
}
impl Animal for Duck {
fn name(&self) -> String { String::from("O pato") }
fn sound(&self) -> String { String::from("quack") }
}
fn main() {
let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cow), Box::new(Duck)];
for animal in &zoo {
println!("{} faz {}", animal.name(), animal.sound());
}
}

Saída:

O cachorro faz au au
A vaca faz muu
O pato faz quack
▶ Experimente você mesmo

Defina uma trait Greeter com fn hello(&self, name: &str) -> String e dois tipos: English e Spanish. Escreva fn make_greeter(lang: &str) -> Box<dyn Greeter>, que devolve Spanish para "es" e English para qualquer outro código. Cumprimente "Mia" para cada código em ["en", "es", "fr"]. Dica: os dois ramos do if precisam devolver o mesmo tipo, então embrulhe cada um em Box::new(...).

Ver solução
trait Greeter {
fn hello(&self, name: &str) -> String;
}
struct English;
struct Spanish;
impl Greeter for English {
fn hello(&self, name: &str) -> String {
format!("Hello, {name}!")
}
}
impl Greeter for Spanish {
fn hello(&self, name: &str) -> String {
format!("¡Hola, {name}!")
}
}
fn make_greeter(lang: &str) -> Box<dyn Greeter> {
if lang == "es" {
Box::new(Spanish)
} else {
Box::new(English)
}
}
fn main() {
for lang in ["en", "es", "fr"] {
let greeter = make_greeter(lang);
println!("{lang}: {}", greeter.hello("Mia"));
}
}

Saída:

en: Hello, Mia!
es: ¡Hola, Mia!
fr: Hello, Mia!
▶ Experimente você mesmo

O "fr" não tem um tipo próprio, então cai no else e recebe a saudação em inglês.

  • Um Vec só guarda um tipo. Para misturar tipos, use um trait object como Box<dyn Trait>.
  • Box<dyn Trait> é dono do valor; &dyn Trait pega emprestado. Um trait object sempre fica atrás de um ponteiro.
  • Genéricos e impl Trait usam dispatch estático (rápido, decidido na compilação). dyn Trait usa dispatch dinâmico (uma consulta na vtable em tempo de execução).
  • Retorne Box<dyn Trait> quando uma função escolhe entre tipos diferentes em tempo de execução.
  • Só traits dyn compatible podem virar trait objects: nada de métodos que retornam Self nem de métodos genéricos (a não ser que tenham where Self: Sized).

Teste o que aprendeu

1. Por que você não pode colocar um Circle e um Square no mesmo Vec comum?

2. O que o dyn em Box<dyn Shape> diz ao Rust?

3. Qual destes usa dispatch estático?

4. Por que Vec<dyn Shape> (sem Box) dá erro?

5. Qual método impede uma trait de ser dyn compatible?

Pontuação: 0 / 5