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».
Il problema: un Vec, tipi diversi
Sezione intitolata “Il problema: un Vec, tipi diversi”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.
Box<dyn Trait>: una lista di forme diverse
Sezione intitolata “Box<dyn Trait>: una lista di forme diverse”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.57quadrato con area 9.00cerchio con area 3.14Leggiamo Box<dyn Shape> un pezzo alla volta:
dyn Shapevuol dire «un tipo che implementaShape: scopriremo quale quando il programma gira».dynsta per dynamic (dinamico).Boxmette il valore nello heap e tiene un puntatore verso di lui (hai conosciutoBoxnella pagina sugli smart pointer). OgniBoxha la stessa dimensione, qualunque cosa contenga. Ecco perché ora ilVecè 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.1Area: 16.0Abbiamo 2 formeUna 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 statico e dinamico
Sezione intitolata “Dispatch statico e dinamico”«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 tipofn static_area(shape: &impl Shape) -> f64 { shape.area()}
// Dispatch dinamico: una sola funzione, il metodo viene cercato a runtimefn 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.
Restituire Box<dyn Trait> da una funzione
Sezione intitolata “Restituire Box<dyn Trait> da una funzione”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.5Ho creato un quadrato di lato 2È 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.
Un esempio pratico: inviare notifiche
Sezione intitolata “Un esempio pratico: inviare notifiche”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(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!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.
I limiti: compatibilità con dyn
Sezione intitolata “I limiti: compatibilità con dyn”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 undyn, Rust non sa cosa siaSelf, 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 traitL’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.
Errori comuni
Sezione intitolata “Errori comuni”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.
Pratica
Sezione intitolata “Pratica”1. Uno zoo in un Vec
Sezione intitolata “1. Uno zoo in un Vec”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 bauLa mucca fa muuL'anatra fa qua qua2. Scegli un saluto a runtime
Sezione intitolata “2. Scegli un saluto a runtime”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!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!.
Riepilogo
Sezione intitolata “Riepilogo”- Un
Vecpuò contenere un solo tipo. Per mescolare tipi diversi, usa un trait object comeBox<dyn Trait>. Box<dyn Trait>possiede il valore;&dyn Traitlo prende in prestito. Un trait object sta sempre dietro un puntatore.- I generici e
impl Traitusano il dispatch statico (veloce, deciso in fase di compilazione).dyn Traitusa 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
Selfe niente metodi generici (a meno che non abbianowhere Self: Sized).
Verifica veloce
Sezione intitolata “Verifica veloce”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
Per approfondire
Sezione intitolata “Per approfondire”- Using Trait Objects to Abstract over Shared Behavior — The Rust Book (in inglese): il capitolo ufficiale, che costruisce una lista di componenti in stile interfaccia grafica con
Box<dyn Draw>. - Returning Traits with dyn — Rust by Example (in inglese): un breve esempio eseguibile di una funzione che restituisce
Box<dyn Trait>. - Trait objects — The Rust Reference (in inglese): le regole esatte per i tipi
dyn Trait, compresi i lifetime e i trait marcatore aggiuntivi comeSend. - Dyn compatibility — The Rust Reference (in inglese): l’elenco completo delle regole che un trait deve rispettare per essere usato con
dyn. - The
dynkeyword — documentazione std (in inglese): una spiegazione rapida didyn, delle vtable e dei compromessi rispetto ai generici.