Salta ai contenuti

Trait object in Rust

Nella pagina sui trait hai scritto funzioni che accettano «qualsiasi tipo che implementa Speak». Nella pagina sui generici hai visto che Rust crea una copia separata di una funzione generica per ogni tipo. Entrambe le cose funzionano benissimo quando conosci il tipo in fase di compilazione. Ma se volessi una sola lista che contiene tanti tipi diversi?

È a questo che serve un trait object. Pensa a un insegnante di musica con una stanza piena di allievi. Uno suona il pianoforte, uno il violino, uno la batteria. L’insegnante non ha bisogno di sapere quale strumento ha ciascuno: dice solo «suonate!» e ognuno suona a modo suo. Un trait object permette al tuo codice di fare lo stesso: «non so il tipo esatto, ma so che sa fare questo».

Mettiamo che tu abbia due forme e voglia tenerle in un solo Vec:

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

Non 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`

Il compilatore dice (in inglese) che i tipi non corrispondono: si aspettava un Circle e ha trovato un Square. Un Vec può contenere un solo tipo. Ogni elemento deve avere la stessa dimensione in memoria, così Rust può metterli in fila uno dopo l’altro. Circle e Square sono tipi diversi, quindi non possono stare nello stesso Vec semplice.

La soluzione ha due parti. Primo: dai a entrambe le forme un trait in comune. Secondo: metti ogni forma in un Box e dichiara che il 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("cerchio") }
fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
}
impl Shape for Square {
fn name(&self) -> String { String::from("quadrato") }
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!("{} con area {:.2}", shape.name(), shape.area());
}
}

Output:

cerchio con area 12.57
quadrato con area 9.00
cerchio con area 3.14
▶ Provalo tu

Leggiamo Box<dyn Shape> un pezzo alla volta:

  • dyn Shape vuol dire «un tipo che implementa Shape: scopriremo quale quando il programma gira». dyn sta per dynamic (dinamico).
  • Box mette il valore nello heap e tiene un puntatore verso di lui (hai conosciuto Box nella pagina sugli smart pointer). Ogni Box ha la stessa dimensione, qualunque cosa contenga. Ecco perché ora il Vec è contento.

Il valore dentro la scatola (più un puntatore nascosto che dice «questo è un Circle») è quello che chiamiamo trait object.

&dyn Trait: prendere in prestito invece di inscatolare

Sezione intitolata “&dyn Trait: prendere in prestito invece di inscatolare”

Non ti serve sempre un Box. Se i valori vivono già da qualche altra parte, va bene anche un riferimento. &dyn Shape vuol dire «un riferimento preso in prestito a un tipo che 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!("Area: {:.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!("Abbiamo {} forme", both.len());
}

Output:

Area: 3.1
Area: 16.0
Abbiamo 2 forme
▶ Provalo tu

Una regola semplice: usa Box<dyn Trait> quando la lista deve possedere i suoi valori, e &dyn Trait quando ti basta prenderli in prestito per un po’.

«Dispatch» vuol dire semplicemente «decidere quale metodo chiamare». Rust può farlo in due modi.

Il dispatch statico avviene con i generici e con impl Trait. Il compilatore conosce il tipo esatto, quindi crea una copia speciale della funzione per ogni tipo (come hai visto nella pagina sui generici). La chiamata va dritta al metodo giusto. È molto veloce.

Il dispatch dinamico avviene con dyn Trait. Il compilatore non conosce il tipo esatto. Quindi ogni trait object porta con sé una piccola tabella nascosta (chiamata vtable) che dice «per questo valore, area si trova qui». A runtime Rust cerca il metodo in quella tabella e poi lo chiama. Quella ricerca è un piccolissimo costo in più.

// Dispatch statico: Rust crea una copia di questa funzione per ogni tipo
fn static_area(shape: &impl Shape) -> f64 {
shape.area()
}
// Dispatch dinamico: una sola funzione, il metodo viene cercato a runtime
fn dynamic_area(shape: &dyn Shape) -> f64 {
shape.area()
}

Le due funzioni danno la stessa risposta per la stessa forma. L’unica differenza è come Rust trova il metodo area giusto. Quindi quale scegliere?

Usa i generici / impl Trait quando… Usa dyn Trait quando…
Ogni chiamata lavora con un solo tipo noto Devi mescolare tipi diversi in una collezione
Vuoi il codice più veloce possibile Decidi il tipo a runtime (per esempio, dall’input dell’utente)
Non ti importa di un programma un po’ più grande Vuoi codice più piccolo e compilazioni più veloci

Una buona scelta di partenza per chi inizia: comincia con i generici o con impl Trait. Passa a dyn quando incontri il problema dei «tipi diversi in una lista». Non preoccuparti troppo della differenza di velocità: nella maggior parte dei programmi non la noterai mai.

A volte una funzione deve decidere a runtime quale tipo restituire. Potresti provare con -> impl Shape, ma funziona solo quando la funzione restituisce sempre lo stesso tipo. Se rami diversi restituiscono tipi diversi, restituisci invece un 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!("un cerchio di raggio {}", self.radius) }
}
impl Shape for Square {
fn describe(&self) -> String { format!("un quadrato di lato {}", self.side) }
}
fn make_shape(kind: &str, size: f64) -> Box<dyn Shape> {
if kind == "cerchio" {
Box::new(Circle { radius: size })
} else {
Box::new(Square { side: size })
}
}
fn main() {
let a = make_shape("cerchio", 1.5);
let b = make_shape("quadrato", 2.0);
println!("Ho creato {}", a.describe());
println!("Ho creato {}", b.describe());
}

Output:

Ho creato un cerchio di raggio 1.5
Ho creato un quadrato di lato 2
▶ Provalo tu

È uno schema comune per le funzioni «fabbrica» (factory), i sistemi di plugin e il codice guidato dalla configurazione: ovunque il tipo esatto dipenda da dati che vedi solo quando il programma gira.

Ecco qualcosa di più vicino a un’applicazione vera. Il tuo programma deve avvisare un utente, ma ogni utente ha scelto canali diversi: email, SMS o entrambi. Ogni canale è un tipo a sé, e tutti condividono un 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!("Email a {}: {}", self.address, message)
}
}
impl Notifier for Sms {
fn send(&self, message: &str) -> String {
format!("SMS a {}: {}", 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("+1-555-0100") }),
];
notify_all(&channels, "Il tuo ordine è stato spedito!");
}

Output:

Email a [email protected]: Il tuo ordine è stato spedito!
SMS a +1-555-0100: Il tuo ordine è stato spedito!
▶ Provalo tu

La parte bella: se più avanti aggiungi un notificatore Push, scrivi una nuova struct e un impl Notifier for Push. La funzione notify_all non cambia per niente.

Non tutti i trait possono diventare trait object. Un trait deve essere compatibile con dyn (dyn compatible; libri e articoli più vecchi la chiamano object safety). Le regole complete sono lunghe, ma due coprono la maggior parte dei casi reali:

  • Un metodo non può restituire Self. (Dietro un dyn, Rust non sa cosa sia Self, quindi non può sapere quanto è grande il valore restituito.)
  • Un metodo non può avere parametri di tipo generici suoi, come fn process<T>(&self, item: T).

Ecco cosa succede se violi la prima regola:

trait Shape {
fn area(&self) -> f64;
fn duplicate(&self) -> Self;
}
// ... later:
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

L’errore è onesto e utile: ti dice esattamente quale metodo è il problema (duplicate usa Self nel tipo di ritorno) e ti suggerisce di spostarlo in un altro trait. Le tue opzioni sono proprio spostare quel metodo in un trait diverso, oppure aggiungere where Self: Sized al metodo. Questo dice a Rust «questo metodo esiste solo sui tipi veri e noti, non sui trait object»:

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

È anche il motivo per cui alcuni trait standard, come Clone, non si possono usare come dyn Clone: clone() restituisce Self.

Dimenticare il Box. Viene la tentazione di scrivere 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`

Il messaggio dice che la dimensione di dyn Shape non si può conoscere in fase di compilazione. Un dyn Shape potrebbe avere qualsiasi dimensione: un piccolo Square o una grossa struct con tanti campi. Rust ha bisogno di una dimensione fissa, quindi un trait object deve sempre stare dietro un puntatore: Box<dyn Shape>, &dyn Shape o Rc<dyn Shape>.

Usare dyn dappertutto. Se una funzione riceve sempre un solo tipo alla volta, impl Trait o i generici sono più semplici e più veloci (la pagina sui trait avanzati dice di più su impl Trait). Tieni dyn per quando hai davvero bisogno di mescolare tipi.

Definisci un trait Animal con fn name(&self) -> String e fn sound(&self) -> String. Implementalo per Dog, Cow e Duck, metti un esemplare di ciascuno in un Vec<Box<dyn Animal>> e scorrilo per stampare il verso di ogni animale. Suggerimento: le struct possono essere vuote (struct Dog;), e ognuna entra con Box::new(...).

Mostra la soluzione
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("Il cane") }
fn sound(&self) -> String { String::from("bau") }
}
impl Animal for Cow {
fn name(&self) -> String { String::from("La mucca") }
fn sound(&self) -> String { String::from("muu") }
}
impl Animal for Duck {
fn name(&self) -> String { String::from("L'anatra") }
fn sound(&self) -> String { String::from("qua qua") }
}
fn main() {
let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cow), Box::new(Duck)];
for animal in &zoo {
println!("{} fa {}", animal.name(), animal.sound());
}
}

Output:

Il cane fa bau
La mucca fa muu
L'anatra fa qua qua
▶ Provalo tu

Definisci un trait Greeter con fn hello(&self, name: &str) -> String, e due tipi: English e Spanish. Scrivi fn make_greeter(lang: &str) -> Box<dyn Greeter>, che restituisce Spanish per "es" e English per qualsiasi altro codice. Saluta "Mia" per ogni codice in ["en", "es", "fr"]. Suggerimento: entrambi i rami dell’if devono restituire lo stesso tipo, quindi avvolgi ciascuno in Box::new(...).

Mostra la soluzione
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"));
}
}

Output:

en: Hello, Mia!
es: ¡Hola, Mia!
fr: Hello, Mia!
▶ Provalo tu

Qui i saluti restano in inglese e in spagnolo, perché sono proprio il risultato che ogni tipo deve produrre. Per esercizio, prova ad aggiungere un tipo Italian che risponde a "it" con Ciao, Mia!.

  • Un Vec può contenere un solo tipo. Per mescolare tipi diversi, usa un trait object come Box<dyn Trait>.
  • Box<dyn Trait> possiede il valore; &dyn Trait lo prende in prestito. Un trait object sta sempre dietro un puntatore.
  • I generici e impl Trait usano il dispatch statico (veloce, deciso in fase di compilazione). dyn Trait usa il dispatch dinamico (una ricerca nella vtable a runtime).
  • Restituisci Box<dyn Trait> quando una funzione sceglie tra tipi diversi a runtime.
  • Solo i trait compatibili con dyn possono diventare trait object: niente metodi che restituiscono Self e niente metodi generici (a meno che non abbiano where Self: Sized).

Verifica veloce

1. Perché non puoi mettere un Circle e uno Square nello stesso Vec semplice?

2. Cosa dice a Rust il dyn in Box<dyn Shape>?

3. Quale di questi usa il dispatch statico?

4. Perché Vec<dyn Shape> (senza Box) è un errore?

5. Quale metodo di un trait gli impedisce di essere compatibile con dyn?

Punteggio: 0 / 5