Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

File: src/lib.rs
pub trait Disegna {
    fn disegna(&self);
}
Listato 18-3: Definizione del trait 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.

File: src/lib.rs
pub trait Disegna {
    fn disegna(&self);
}

pub struct Schermo {
    pub componenti: Vec<Box<dyn Disegna>>,
}
Listato 18-4: Definizione della struct 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.

File: src/lib.rs
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();
        }
    }
}
Listato 18-5: Un metodo esegui in Schermo che chiama disegna per ogni componente

Questo 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.

File: src/lib.rs
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();
        }
    }
}
Listato 18-6: Implementazione alternativa della struct Schermo e del metodo esegui usando type generici e vincoli di trait

Questo 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.

File: src/lib.rs
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
    }
}
Listato 18-7: La struct 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.

File: src/main.rs
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() {}
Listato 18-8: Un altro crate che usa 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:

File: src/main.rs
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();
}
Listato 18-9: Uso di oggetti trait per memorizzare valori di differente type che implementano il medesimo trait

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.

File: src/main.rs
use gui::Schermo;

fn main() {
    let schermo = Schermo {
        componenti: vec![Box::new(String::from("Ciao"))],
    };

    schermo.esegui();
}
Listato 18-10: Tentativo di usare un type che non implementa il trait dell’oggetto trait

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.