Usare gli Oggetti Trait per Astrarre Comportamenti Condivisi
Nel Capitolo 8, abbiamo detto che un limite dei vettori è che possono contenere
elementi di un solo type. Abbiamo trovato una soluzione nel Listato 8-9
definendo una enum CellaFoglioDiCalcolo
che aveva varianti per interi, float
e testo. Questo ci permetteva di mettere type diversi in ogni cella e comunque
avere un vettore che rappresentava una riga di celle. Questa è una soluzione
perfetta quando gli elementi intercambiabili sono un insieme fisso di type
noti a tempo di compilazione.
Però a volte vogliamo che chi usa la nostra libreria possa estendere l’insieme
di type validi in una situazione. Per mostrare come farlo, creeremo un esempio
di interfaccia grafica (GUI) che scorre una lista di elementi, chiamando per
ognuno un metodo disegna
per disegnarlo a schermo, una tecnica comune negli
strumenti GUI. Creeremo un crate libreria chiamato gui
che contiene la
struttura base di una libreria GUI. Questo crate includerà type da usare,
come Bottone
o CampoTesto
. Inoltre, gli utenti della libreria vorranno
creare i propri type disegnabili: per esempio, uno aggiungerà un Immagine
e
un altro una BoxSelezione
.
Quando scriviamo la libreria, non possiamo sapere e definire tutti i type che
altri programmatori potrebbero voler creare. Ma sappiamo che gui
deve tenere
traccia di molti valori di type diversi e deve chiamare un metodo disegna
su
ognuno di questi valori. Non deve sapere esattamente cosa succede quando chiama
disegna
, solo che quel metodo è disponibile.
In un linguaggio con ereditarietà, potremmo definire una classe Componente
con
un metodo disegna
. Le altre classi, come Bottone
, Immagine
e
BoxSelezione
, erediterebbero da Componente
e quindi avrebbero il metodo
disegna
. Ognuno potrebbe sovrascriverlo per comportamenti personalizzati, ma
il framework tratterebbe tutti i type come istanze di Componente
e chiamare
disegna
. Ma dato che Rust non ha ereditarietà, serve un altro modo per
strutturare gui
permettendo agli utenti di creare nuovi type compatibili con
la libreria.
Definire un Trait per un Comportamento Comune
Per implementare il comportamento che vogliamo in gui
, definiamo un trait
chiamato Disegna
con un metodo disegna
. Poi definiamo un vettore che
contiene oggetti trait. Un oggetto trait punta sia a un’istanza di un type
che implementa il trait, sia a una tabella usata per cercare durante
l’esecuzione i metodi trait su quel type. Creiamo un oggetto trait
specificando un puntatore, come un reference &
o uno puntatore intelligente
Box<T>
, poi la parola chiave dyn
e infine specifichiamo il trait
rilevante. (Parleremo del motivo per cui gli oggetti trait devono usare un
puntatore in “Type a Dimensione Dinamica e il Trait
Sized
” nel Capitolo 20.) Possiamo usare
oggetti trait al posto di type generici o concreti. Ovunque usiamo un
oggetto trait, il sistema dei type di Rust garantisce durante la
compilazione che ogni valore in quel contesto implementi il trait dell’oggetto
trait, quindi non serve conoscere tutti i type possibili al momento della
compilazione.
Abbiamo detto che in Rust evitiamo di chiamare “oggetti” struct ed enum per
distinguerli dagli oggetti di altri linguaggi. In una struct o enum, dati e
comportamento in blocchi impl
sono separati, mentre in altri linguaggi dati e
comportamento uniti formano un oggetto. E quindi gli oggetti trait sono in
qualche modo simili agli oggetti in altri linguaggi nel senso che combinano dati
e comportamento. Ma gli oggetti trait differiscono dalla tradizionale
definizione di oggetto in altri linguaggi perché non possono contenere dati. Gli
oggetti trait non hanno la completezza che si trova in altri linguaggi:
servono specificamente solo per astrarre comportamenti comuni.
Il Listato 18-3 mostra come definire un trait Disegna
con un metodo
disegna
.
pub trait Disegna {
fn disegna(&self);
}
Disegna
La sintassi dovrebbe essere familiare dalle discussioni sui trait nel Capitolo
10. Poi, nel Listato 18-4, definiamo una struct Schermo
che contiene un
vettore componenti
. Questo vettore è di type Box<dyn Disegna>
, che è un
oggetto trait; è un contenitore per qualunque type in una Box
che
implementi il trait Disegna
.
pub trait Disegna {
fn disegna(&self);
}
pub struct Schermo {
pub componenti: Vec<Box<dyn Disegna>>,
}
Schermo
con una campo componenti
contenente un vettore di oggetti trait che implementano Disegna
Definiamo un metodo esegui
su Schermo
che chiama disegna
su ogni elemento
di componenti
, come nel Listato 18-5.
pub trait Disegna {
fn disegna(&self);
}
pub struct Schermo {
pub componenti: Vec<Box<dyn Disegna>>,
}
impl Schermo {
pub fn esegui(&self) {
for componente in self.componenti.iter() {
componente.disegna();
}
}
}
esegui
in Schermo
che chiama disegna
per ogni componenteQuesto funziona diversamente da una struct con un parametro di type generico
con vincoli di trait. Un type generico può essere sostituito da un solo
type concreto alla volta, mentre gli oggetti trait permettono a più type
concreti di poter essere usati per quel ruolo durante l’esecuzione. Per esempio,
potremmo aver definito la struct Schermo
con un type generico e un vincolo
di trait, come nel Listato 18-6.
pub trait Disegna {
fn disegna(&self);
}
pub struct Schermo<T: Disegna> {
pub componenti: Vec<T>,
}
impl<T> Screen<T>
where
T: Disegna,
{
pub fn esegui(&self) {
for componente in self.componenti.iter() {
componente.disegna();
}
}
}
Schermo
e del metodo esegui
usando type generici e vincoli di traitQuesto limita a istanze di Schermo
con una lista di componenti tutte dello
stesso type, per esempio tutti Bottone
o tutti CampoTesto
. Se si hanno
solo collezioni omogenee, usare generici è preferibile perché il codice sarà
monomorfizzato durante la compilazione usando i type concreti.
Con gli oggetti trait, invece, una singola istanza di Schermo
può contenere
un Vec<T>
con una Box<Bottone>
e una Box<CampoTesto>
insieme. Vediamo come
funziona e poi parleremo delle implicazioni sulle prestazioni durante
l’esecuzione.
Implementare il Trait
Ora aggiungiamo type che implementano il trait Disegna
. Aggiungiamo un
type Bottone
. Scrivere una vera e propria libreria GUI va oltre lo scopo
del libro, quindi il metodo disegna
in non contiene nulla di utile nel corpo.
Per farsi un’idea di una possibile implementazione, un Bottone
potrebbe avere
campi larghezza
, altezza
e etichetta
, come nel Listato 18-7.
pub trait Disegna {
fn disegna(&self);
}
pub struct Schermo {
pub componenti: Vec<Box<dyn Disegna>>,
}
impl Schermo {
pub fn esegui(&self) {
for componente in self.componenti.iter() {
componente.disegna();
}
}
}
pub struct Bottone {
pub larghezza: u32,
pub altezza: u32,
pub etichetta: String,
}
impl Disegna for Bottone {
fn disegna(&self) {
// codice per disegnare il bottone
}
}
Bottone
che implementa il trait Disegna
I campi larghezza
, altezza
e etichetta
su Bottone
sono diversi dagli
altri componenti; per esempio, un type CampoTesto
potrebbe avere gli stessi
campi più un campo temporaneo
. Ogni type che vogliamo disegnare implementerà
il trait Disegna
usando codice diverso in disegna
per definire come
disegnarsi, come fa Bottone
(senza codice GUI reale). Bottone
potrebbe
anche avere altri metodi nel suo blocco impl
, ad esempio per gestire cosa
succede al click, metodi che non si applicano a type come CampoTesto
.
Se qualcuno che usa la libreria definisce una BoxSelezione
con campi
larghezza
, altezza
e opzioni
, implementerà il trait Disegna
anche su
BoxSelezione
, come nel Listato 18-8.
use gui::Disegna;
struct BoxSelezione {
larghezza: u32,
altezza: u32,
opzioni: Vec<String>,
}
impl Disegna for BoxSelezione {
fn disegna(&self) {
// codice per disegnare il box di selezione
}
}
fn main() {}
gui
e implementa Disegna
su BoxSelezione
Chi userà la nostra libreria può quindi scrivere la funzione main
creando
un’istanza di Schermo
. All’istanza di Schermo
aggiunge una BoxSelezione
e
un Bottone
mettendoli in Box<T>
, facendoli diventare oggetti trait. Poi
chiama esegui
sull’istanza di Schermo
, che a sua volta chiama disegna
su
ogni componente. Il Listato 18-9 mostra l’implementazione:
use gui::Disegna;
struct BoxSelezione {
larghezza: u32,
altezza: u32,
opzioni: Vec<String>,
}
impl Disegna for BoxSelezione {
fn disegna(&self) {
// code to actually draw a select box
}
}
use gui::{Bottone, Schermo};
fn main() {
let schermo = Schermo {
componenti: vec![
Box::new(BoxSelezione {
larghezza: 75,
altezza: 10,
opzioni: vec![
String::from("Sì"),
String::from("Forse"),
String::from("No"),
],
}),
Box::new(Bottone {
larghezza: 50,
altezza: 10,
etichetta: String::from("OK"),
}),
],
};
schermo.esegui();
}
Quando scriviamo la libreria, non sapevamo che qualcuno avrebbe aggiunto
BoxSelezione
, ma l’implementazione di Schermo
funziona comunque con quel
type perché BoxSelezione
implementa il trait Disegna
che quindi ha il
metodo disegna
.
Questo concetto, preoccuparsi solo dei messaggi a cui un valore risponde invece
che del type concreto, somiglia al duck typing (tipizzazione ad anatra)
nei linguaggi a tipizzazione dinamica: se cammina come un’anatra e fa “qua
qua”, allora è un’anatra! Nel metodo esegui
di Schermo
nel Listato 18-5,
esegui
non sa di che type concreto è ogni componente, non controlla se è
un’istanza di Bottone
o BoxSelezione
, chiama semplicemente disegna
sul
componente. Specificando Box<dyn Disegna>
come type dei valori nel vettore
componenti
, abbiamo definito Schermo
per accettare solo valori su cui si può
chiamare disegna
.
Il vantaggio di usare oggetti trait e il sistema dei type di Rust per scrivere codice simile a quello con duck typing è che non dobbiamo mai controllare durante l’esecuzione se un valore implementa un metodo o temere errori se non l’implementa ma lo chiamiamo comunque. Rust non compila il codice se i valori non implementano i trait richiesti dagli oggetti trait.
Per esempio, il Listato 18-10 mostra cosa succede se proviamo a creare uno
Schermo
con una String
come componente.
use gui::Schermo;
fn main() {
let schermo = Schermo {
componenti: vec![Box::new(String::from("Ciao"))],
};
schermo.esegui();
}
Avremo questo errore perché String
non implementa il trait Disegna
:
$ cargo run
Compiling gui v0.1.0 (file:///progetti/gui)
error[E0277]: the trait bound `String: Disegna` is not satisfied
--> src/main.rs:5:26
|
5 | componenti: vec![Box::new(String::from("Ciao"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Disegna` is not implemented for `String`
|
= help: the trait `Disegna` is implemented for `Bottone`
= note: required for the cast from `Box<String>` to `Box<dyn Disegna>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
L’errore ci dice che o stiamo passando a Schermo
qualcosa che non volevamo,
oppure dobbiamo implementare Disegna
su String
per permettere che Schermo
chiami disegna
anche su quel type.
Eseguire il Dynamic Dispatch
Come detto in “Prestazioni del Codice utilizzando Type Generici” nel Capitolo 10 sulla monomorfizzazione eseguita dal compilatore per i type generici: il compilatore genera implementazioni non generiche di funzioni e metodi per ogni type concreto usato al posto del type generico. Il codice che risulta dalla monomorfizzazione usa static dispatch, durante la compilazione il compilatore conosce quale metodo stai chiamando. Questo è all’opposto del dynamic dispatch, dove il compilatore non può sapere durante la compilazione quale metodo stai chiamando. Nel caso di dynamic dispatch, il compilatore genera codice che solo durante l’esecuzione saprà quale metodo chiamare.
Quando usiamo oggetti trait, Rust deve usare il dynamic dispatch. Il compilatore non conosce tutti i type che possono essere usati con il codice che usa oggetti trait, quindi non sa quale metodo di quale type chiamare. Durante l’esecuzione, Rust usa i puntatori dentro l’oggetto trait per decidere il metodo da chiamare. Questa ricerca ha un costo prestazionale che non c’è con lo static dispatch. Inoltre, il dynamic dispatch impedisce che il compilatore possa fare alcune ottimizzazioni, e Rust ha regole su dove si può usare dynamic dispatch, chiamate compatibilità dyn. Queste regole esulano da questa discussione, ma puoi leggere di più a riguardo nella documentazione. Però abbiamo guadagnato più flessibilità nel codice del Listato 18-5 e possiamo supportarla come nel Listato 18-9, quindi è un compromesso da considerare.