Shopping cart

0

Cart

  • 0 item

Nessun prodotto nel carrello.

All categories

SE VUOI PRENDERE LA CERTIFICAZIONE PER QUESTO CORSO CLICCA QUI

Utilizzo di Oggetti di Tratto che Consentono Valori di Diversi Tipi

Nel Capitolo 8, abbiamo menzionato che una limitazione dei vettori è che possono memorizzare elementi di un solo tipo. Abbiamo creato una soluzione alternativa nel Listato 8-9 dove abbiamo definito un enum chiamato SpreadsheetCell che aveva varianti per contenere interi, float e testo. Ciò significava che potevamo memorizzare tipi di dati diversi in ogni cella e avere comunque un vettore che rappresentava una riga di celle. Questa è una soluzione perfettamente valida quando i nostri elementi intercambiabili sono un insieme fisso di tipi che conosciamo quando il nostro codice viene compilato.

Tuttavia, a volte vogliamo che l’utente della nostra libreria possa estendere l’insieme di tipi che sono validi in una situazione particolare. Per mostrare come potremmo realizzare questo, creeremo uno strumento di interfaccia grafica (GUI) di esempio che itera attraverso un elenco di elementi, chiamando un metodo draw su ciascuno per disegnarlo sullo schermo, una tecnica comune per gli strumenti GUI. Creeremo una libreria chiamata gui che contiene la struttura di una libreria GUI. Questa libreria potrebbe includere alcuni tipi per le persone da utilizzare, come Button o TextField. Inoltre, gli utenti di gui vorranno creare i propri tipi che possono essere disegnati: ad esempio, un programmatore potrebbe aggiungere un’Immagine e un altro potrebbe aggiungere una SelectBox.

Non implementeremo una libreria GUI completamente sviluppata per questo esempio, ma mostreremo come i pezzi si adatterebbero insieme. Al momento della scrittura della libreria, non possiamo sapere e definire tutti i tipi che altri programmatori potrebbero voler creare. Ma sappiamo che gui deve tenere traccia di molti valori di tipi diversi e chiamare un metodo draw su ognuno di questi valori di tipo diverso. Non è necessario sapere esattamente cosa succederà quando chiamiamo il metodo draw, solo che il valore avrà quel metodo disponibile per chiamarlo.

Per fare ciò in un linguaggio con l’ereditarietà, potremmo definire una classe chiamata Component che ha un metodo chiamato draw su di essa. Le altre classi, come Button, Image e SelectBox, erediterebbero da Component e quindi erediterebbero il metodo draw. Ognuna potrebbe sovrascrivere il metodo draw per definire il proprio comportamento personalizzato, ma il framework potrebbe trattare tutti i tipi come se fossero istanze di Component e chiamarli draw su di essi. Ma poiché Rust non ha l’ereditarietà, abbiamo bisogno di un altro modo per strutturare la libreria gui per consentire agli utenti di estenderla con nuovi tipi.

Definizione di un Tratto per Comportamenti Comuni

Per implementare il comportamento che vogliamo che gui abbia, definiremo un tratto chiamato Draw che avrà un metodo chiamato draw. Quindi possiamo definire un vettore che accetta un oggetto di tratto. Un oggetto di tratto punta sia a un’istanza di un tipo che implementa il tratto specificato che a una tabella utilizzata per cercare i metodi del tratto su quel tipo a tempo di esecuzione. Creiamo un oggetto di tratto specificando un qualche tipo di puntatore, come un riferimento & o un puntatore intelligente Box<T>, quindi la parola chiave dyn e quindi specificando il tratto rilevante. (Parleremo della ragione per cui gli oggetti di tratto devono utilizzare un puntatore nel Capitolo 19 nella sezione “Tipi di Dimensioni Dinamiche e il Tratto Sized.”) Possiamo usare gli oggetti di tratto al posto di un tipo generico o concreto. Ovunque usiamo un oggetto di tratto, il sistema di tipi di Rust garantirà a tempo di compilazione che qualsiasi valore utilizzato in quel contesto implementi il tratto dell’oggetto di tratto. Di conseguenza, non dobbiamo conoscere tutti i tipi possibili a tempo di compilazione.

Abbiamo menzionato che, in Rust, evitiamo di chiamare strutture ed enum “oggetti” per distinguerli dagli oggetti degli altri linguaggi. In una struttura o enum, i dati nei campi della struttura e il comportamento nei blocchi impl sono separati, mentre in altri linguaggi, i dati e il comportamento combinati in un unico concetto sono spesso etichettati come oggetto. Tuttavia, gli oggetti di tratto sono più simili agli oggetti in altri linguaggi nel senso che combinano dati e comportamento. Ma gli oggetti di tratto differiscono dagli oggetti tradizionali nel senso che non possiamo aggiungere dati a un oggetto di tratto. Gli oggetti di tratto non sono così utili come oggetti in altri linguaggi: il loro scopo specifico è consentire l’astrazione attraverso comportamenti comuni.

Il Listato 17-3 mostra come definire un tratto chiamato Draw con un metodo chiamato draw:

Filename: src/lib.rs

rust

pub trait Draw {
fn draw(&self);
}

Questo funziona in modo diverso rispetto alla definizione di una struttura che utilizza un parametro di tipo generico con vincoli di tratto. Un parametro di tipo generico può essere sostituito solo con un tipo concreto alla volta, mentre gli oggetti di tratto consentono a più tipi concreti di riempire l’oggetto di tratto a tempo di esecuzione. Ad esempio, avremmo potuto definire la struttura Screen utilizzando un tipo generico e un vincolo di tratto come nel Listato 17-6:

Filename: src/lib.rs

rust

pub struct Screen<T: Draw> {
pub components: Vec<T>,
}

impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}

Questa restrizione ci limita a un’istanza di Screen che ha un elenco di componenti tutti del tipo Button o tutti del tipo TextField. Se avete solo raccolte omogenee, utilizzare i generici e i vincoli di tratto è preferibile perché le definizioni saranno monomorfizzate a tempo di compilazione per utilizzare i tipi concreti.

D’altra parte, con il metodo che utilizza gli oggetti di tratto, un’istanza di Screen può contenere un Vec<T> che contiene un Box<Button> così come un Box<TextField>. Vediamo come funziona questo, e poi parleremo delle implicazioni sulle prestazioni a tempo di esecuzione.

Implementazione del Tratto

Ora aggiungeremo alcuni tipi che implementano il tratto Draw. Forniremo il tipo Button. Di nuovo, implementare effettivamente una libreria GUI è al di là dello scopo di questo libro, quindi il metodo draw non avrà alcuna implementazione utile nel suo corpo. Per immaginare come potrebbe essere l’implementazione, una struttura Button potrebbe avere campi per larghezza, altezza e etichetta, come mostrato nel Listato 17-7:

Filename: src/lib.rs

rust

pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}

impl Draw for Button {
fn draw(&self) {
// codice per disegnare effettivamente un pulsante
}
}

I campi larghezza, altezza e etichetta su Button saranno diversi dai campi su altri componenti; ad esempio, un tipo TextField potrebbe avere gli stessi campi più un campo per il segnaposto. Ognuno dei tipi che vogliamo disegnare sullo schermo implementerà il tratto Draw ma utilizzerà un codice diverso nel metodo draw per definire come disegnare quel particolare tipo, come fa Button qui (senza il codice GUI effettivo, come accennato). Il tipo Button, ad esempio, potrebbe avere un blocco impl aggiuntivo contenente metodi relativi a cosa succede quando un utente fa clic sul pulsante. Questi tipi di metodi non si applicheranno ai tipi come TextField.

Se qualcuno che utilizza la nostra libreria decide di implementare una struttura SelectBox che ha campi larghezza, altezza e opzioni, implementerà anche il tratto Draw sul tipo SelectBox, come mostrato nel Listato 17-8:

Filename: src/main.rs

rust

use gui::Draw; struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}

impl Draw for SelectBox {
fn draw(&self) {
// codice per disegnare effettivamente una casella di selezione
}
}

Ora l’utente della nostra libreria può scrivere la propria funzione main per creare un’istanza di Screen. All’istanza di Screen, possono aggiungere una SelectBox e un Button mettendo ognuno in un Box<T> per diventare un oggetto di tratto. Possono quindi chiamare il metodo run sull’istanza di Screen, che chiamerà draw su ciascuno dei componenti. Il Listato 17-9 mostra questa implementazione:

Filename: src/main.rs

rust

use gui::{Button, Screen}; fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Sì"),
String::from("Forse"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};

screen.run();
}

Quando abbiamo scritto la libreria, non sapevamo che qualcuno avrebbe aggiunto il tipo SelectBox, ma la nostra implementazione di Screen è stata in grado di operare sul nuovo tipo e disegnarlo perché SelectBox implementa il tratto Draw, il che significa che implementa il metodo draw.

Questo concetto, di essere interessati solo ai messaggi a cui un valore risponde piuttosto che al tipo concreto del valore, è simile al concetto di duck typing nei linguaggi tipizzati dinamicamente: se cammina come un’anatra e fa il verso come un’anatra, allora deve essere un’anatra! Nell’implementazione di run su Screen nel Listato 17-5, run non ha bisogno di sapere qual è il tipo concreto di ciascun componente. Non controlla se un componente è un’istanza di un Button o di una SelectBox, semplicemente chiama il metodo draw sul componente. Specificando Box<dyn Draw> come tipo dei valori nel vettore dei componenti, abbiamo definito Screen per avere bisogno di valori su cui possiamo chiamare il metodo draw.

Il vantaggio dell’uso degli oggetti di tratto e del sistema di tipo di Rust per scrivere codice simile al codice che utilizza il duck typing è che non dobbiamo mai controllare se un valore implementa un metodo particolare a runtime o preoccuparci di ottenere errori se un valore non implementa un metodo ma lo chiamiamo comunque. Rust non compilerà il nostro codice se i valori non implementano i tratti di cui hanno bisogno gli oggetti di tratto.

Ad esempio, il Listato 17-10 mostra cosa succede se proviamo a creare uno Screen con una Stringa come componente:

Filename: src/main.rs

rust

// [Questo codice non viene compilato!]
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Ciao"))],
};

screen.run();
}

Otterremo questo errore perché String non implementa il tratto Draw:

$ cargo run Compiling gui v0.1.0 (file:///projects/gui) error[E0277]: the trait bound String: Draw is not satisfied –> src/main.rs:5:26 | 5 | components: vec![Box::new(String::from(“Ciao”))], | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ il tratto Draw non è implementato per String | = help: il tratto Draw è implementato per Button = nota: richiesto per il cast da String al tipo di oggetto dyn Draw

Questo errore ci informa che stiamo passando qualcosa a Screen che non volevamo passare e quindi dovremmo passare un tipo diverso o dovremmo implementare Draw su String in modo che Screen sia in grado di chiamare draw su di esso.

Gli Oggetti di Tratto Eseguono la Dispersione Dinamica

Ricordiamo nella sezione “Prestazioni del Codice che Utilizza i Generici” nel Capitolo 10 la nostra discussione sul processo di monomorfizzazione eseguito dal compilatore quando usiamo vincoli di tratto sui generici: il compilatore genera implementazioni non generiche di funzioni e metodi per ogni tipo concreto che usiamo al posto di un parametro di tipo generico. Il codice che deriva dalla monomorfizzazione sta eseguendo una dispersione statica, che è quando il compilatore sa quale metodo stai chiamando a tempo di compilazione. Questo è opposto alla dispersione dinamica, che è quando il compilatore non può dire a tempo di compilazione quale metodo stai chiamando. Nei casi di dispersione dinamica, il compilatore emette codice che a runtime capirà quale metodo chiamare.

Quando usiamo gli oggetti di tratto, Rust deve utilizzare la dispersione dinamica. Il compilatore non conosce tutti i tipi che potrebbero essere utilizzati con il codice che utilizza gli oggetti di tratto, quindi non sa quale metodo implementato su quale tipo chiamare. Invece, a runtime, Rust utilizza i puntatori all’interno dell’oggetto di tratto per sapere quale metodo chiamare. Questa ricerca comporta un costo a tempo di esecuzione che non si verifica con la dispersione statica. La dispersione dinamica impedisce anche al compilatore di scegliere di incorporare il codice di un metodo, il che a sua volta impedisce alcune ottimizzazioni. Tuttavia, abbiamo ottenuto una flessibilità aggiuntiva nel codice che abbiamo scritto nel Listato 17-5 e siamo stati in grado di supportare nel Listato 17-9, quindi è un compromesso da considerare.

Leave a Reply

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *