Type Avanzati
Il sistema dei type di Rust ha alcune caratteristiche che abbiamo già
menzionato ma non ancora discusso. Inizieremo parlando dei newtype in
generale, esaminando perché i newtype sono utili come type. Poi passeremo
agli alias di type, una caratteristica simile ai newtype ma con una
semantica leggermente diversa. Discuteremo anche del type ! e dei type a
dimensione dinamica.
Sicurezza dei Type e Astrazione Con il Modello Newtype
Questa sezione presuppone che tu abbia letto la sezione precedente
“Implementare Trait Esterni con il Modello
Newtype”. Il modello newtype è
utile anche per compiti oltre quelli che abbiamo già discusso, tra cui far
rispettare staticamente che i valori non vengano confusi e indicare le unità di
misura di un valore. Hai visto un esempio dell’uso dei newtype per indicare
unità di misura nel Listato 20-16: ricorda che le struct Millimetri e
Metri incapsulavano valori u32 come newtype. Se scrivessimo una funzione
con un parametro di type Millimetri, non potremmo compilare un programma che
accidentalmente provasse a chiamare quella funzione con un valore di type
Metri o con un semplice u32.
Possiamo anche usare il modello newtype per astrarre alcuni dettagli di implementazione di un type: il nuovo type può esporre una API pubblica diversa dall’API del type interno privato.
I newtype possono anche nascondere l’implementazione interna. Ad esempio,
potremmo fornire un type Persone per incapsulare un HashMap<i32, String>
che associa l’ID di una persona al nome. Il codice che usa Persone
interagirebbe solo con l’API pubblica che definiamo, ad esempio un metodo per
aggiungere un nome alla collezione Persone; quel codice non avrebbe bisogno di
sapere che internamente associamo un ID i32 ai nomi. Il modello newtype è un
modo leggero di ottenere l’incapsulamento per nasconde dettagli di
implementazione, come abbiamo discusso in “Incapsulamento che Nasconde i
Dettagli di Implementazione” nel
Capitolo 18.
Sinonimi e Alias di Type
Rust permette di dichiarare un alias di type per dare a un type esistente
un altro nome. Per fare questa cosa usiamo la parola chiave type. Ad esempio,
possiamo creare l’alias Chilometri per i32 così:
fn main() { type Chilometri = i32; let x: i32 = 5; let y: Chilometri = 5; println!("x + y = {}", x + y); }
Ora Chilometri è un sinonimo di i32; a differenza dei type Millimetri
e Metri che abbiamo creato nel Listato 20-16, Chilometri non è un type
distinto e separato. I valori di type Chilometri saranno trattati allo
stesso modo di quelli di type i32:
fn main() { type Chilometri = i32; let x: i32 = 5; let y: Chilometri = 5; println!("x + y = {}", x + y); }
Poiché Chilometri e i32 sono lo stesso type, possiamo sommare valori di
entrambi i type e passare valori di type Chilometri a funzioni che
accettano parametri i32. Tuttavia, usando questo metodo non otteniamo i
benefici di controllo dei type che otterremmo con il modello newtype
discusso prima. In altre parole, se confondiamo valori di type Chilometri e
i32 da qualche parte, il compilatore non ci darà errore.
Il caso d’uso principale per i sinonimi di type è ridurre la ripetizione. Ad esempio, potremmo avere una definizione di type un po’ lunga:
Box<dyn Fn() + Send + 'static>
Scrivere questo type lungo nelle firme delle funzioni e come annotazioni di type in tutto il codice può essere verboso e soggetto a errori. Immagina un progetto pieno di codice come quello del Listato 20-25.
fn main() { let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("ciao")); fn prende_type_lungo(f: Box<dyn Fn() + Send + 'static>) { // --taglio-- } fn ritorna_type_lungo() -> Box<dyn Fn() + Send + 'static> { // --taglio-- Box::new(|| ()) } }
Un alias di type rende questo codice più gestibile riducendo la ripetizione.
Nel Listato 20-26, abbiamo introdotto un alias chiamato Thunk per il type
verboso e possiamo sostituire tutti gli usi di quel type con l’alias più
corto Thunk.
fn main() { type Thunk = Box<dyn Fn() + Send + 'static>; let f: Thunk = Box::new(|| println!("ciao")); fn prende_type_lungo(f: Thunk) { // --taglio-- } fn ritorna_type_lungo() -> Thunk { // --taglio-- Box::new(|| ()) } }
Thunk, per ridurre la ripetizioneQuesto codice è molto più facile da leggere e scrivere! Scegliere un nome significativo per un alias di type può anche aiutare a comunicare chiaramente la nostra intenzione (thunk è una parola tecnica usata per indicare codice che verrà eseguito e valutato in un secondo momento, quindi è un nome appropriato per una closure che viene memorizzata).
Gli alias di type sono anche comunemente usati con il type Result<T, E>
per ridurre la ripetizione. Considera il modulo std::io nella libreria
standard. Le operazioni I/O spesso restituiscono un Result<T, E> per gestire
le situazioni in cui le operazioni possono fallire. Questa libreria ha una
struct std::io::Error che rappresenta tutti i possibili errori di I/O. Molte
funzioni in std::io restituiscono un Result<T, E> dove E è
std::io::Error, come nelle funzioni del trait Write:
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
Il Result<..., Error> si ripete molto. Per questo motivo, std::io ha questa
dichiarazione di alias di type:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
Poiché questa dichiarazione è nel modulo std::io, possiamo usare l’alias
completamente qualificato std::io::Result<T>; cioè, un Result<T, E> con E
riempito come std::io::Error. Le firme delle funzioni del trait Write
diventano così:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
L’alias di type aiuta in due modi: rende il codice più facile da scrivere e
leggere e ci fornisce un’interfaccia coerente in tutto std::io. Poiché è un
alias, è solo un altro Result<T, E>, il che significa che possiamo usare
tutti i metodi che funzionano su Result<T, E>, oltre alla sintassi speciale
come l’operatore ?.
Il Type Never Che Non Ritorna Mai
Rust ha un type speciale chiamato ! che in gergo di teoria dei type è
chiamato type vuoto perché non ha valori. Preferiamo chiamarlo type
never (type mai) perché rappresenta il type di ritorno di una funzione
che non restituirà mai nulla. Ecco un esempio:
fn bar() -> ! {
// --taglio--
panic!();
}
Questo codice significa “la funzione bar non restituirà mai”. Le funzioni che
non restituiscono mai sono chiamate funzioni divergenti. Non possiamo creare
valori di type !, per cui bar non potrà mai restituirli.
Ma a cosa serve un type per cui non si possono creare valori? Ricorda il codice del Listato 2-5, parte del gioco degli indovinelli; ne riproduciamo un pezzo qui nel Listato 20-27.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Indovina il numero!");
let numero_segreto = rand::thread_rng().gen_range(1..=100);
println!("Il numero segreto è: {numero_segreto}");
loop {
println!("Inserisci la tua ipotesi.");
let mut ipotesi = String::new();
// --taglio--
io::stdin()
.read_line(&mut ipotesi)
.expect("Errore di lettura");
let ipotesi: u32 = match ipotesi.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("Hai ipotizzato: {ipotesi}");
// --taglio--
match ipotesi.cmp(&numero_segreto) {
Ordering::Less => println!("Troppo piccolo!"),
Ordering::Greater => println!("Troppo grande!"),
Ordering::Equal => {
println!("Hai indovinato!");
break;
}
}
}
}
match con un ramo che finisce con continueAll’epoca abbiamo trascurato alcuni dettagli di questo codice. Nella sezione
“Controllare il Flusso con il costrutto
match” del Capitolo 6 abbiamo spiegato che
tutti i rami di un match devono ritornare lo stesso type. Quindi, per
esempio, il seguente codice non funziona:
fn main() {
let ipotesi = "3";
let ipotesi = match ipotesi.trim().parse() {
Ok(_) => 5,
Err(_) => "ciao",
};
}
Il type di ipotesi in questo codice dovrebbe essere un intero e una
stringa, e Rust richiede che ipotesi sia di un solo type. Allora cosa
ritorna continue? Come facciamo a ritornare un u32 da un ramo e avere un
altro ramo che termina con continue nel Listato 20-27?
Come avrai intuito, continue ha un valore di type !. Questo significa che
quando Rust calcola il type di ipotesi, guarda entrambi i rami: il primo con
valore u32 e il secondo con valore !. Poiché ! non può avere un valore,
Rust decide che il type di ipotesi è u32.
Il modo formale di descrivere questo comportamento è che le espressioni di
type ! possono essere forzate in qualsiasi altro type. È permesso
terminare questo ramo di match con continue perché continue non
restituisce un valore; invece, sposta il controllo in cima al ciclo, quindi nel
caso di Err non assegniamo mai un valore a ipotesi.
Il type never è utile anche con la macro panic!. Ricorda la funzione
unwrap che chiamiamo sui valori Option<T> per ottenere un valore o fare
panic, con questa definizione:
enum Option<T> {
Some(T),
None,
}
use crate::Option::*;
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("chiamato `Option::unwrap()` su un valore `None`"),
}
}
}
In questo codice, succede la stessa cosa vista nel match del Listato 20-27:
Rust vede che val ha type T e panic! ha type !, quindi il risultato
dell’espressione complessiva match è T. Questo codice funziona perché
panic! non produce un valore; termina il programma. Nel caso None non
ritorneremo un valore da unwrap, quindi questo codice è valido.
Un’ultima espressione che ha type ! è un loop:
fn main() {
print!("sempre ");
loop {
print!("e per sempre ");
}
}
Qui il loop non termina mai, per cui il valore dell’espressione è !. Questo
non varrebbe se usassimo un break perché il ciclo terminerebbe quando incontra
break.
Type a Dimensione Dinamica e il Trait Sized
Rust ha bisogno di conoscere alcune informazioni sui type, tipo quanto spazio allocare per un valore di quel type. Questo rende un po’ complicato il concetto di type a dimensione dinamica. Detti anche DST o type non dimensionati, consentono di scrivere codice che usa valori la cui dimensione è nota solo in fase di esecuzione.
Parliamo nel dettaglio di un type a dimensione dinamica chiamato str, che
abbiamo usato spesso nel libro. Proprio così, non &str ma str da solo è un
DST. In molti casi, come nel caso di stringhe inserite da un utente, non
possiamo sapere la lunghezza della stringa a priori se non durante l’esecuzione.
Questo significa che non possiamo creare una variabile di type str, né
ricevere un argomento di type str. Considera il seguente codice, che non
funziona:
fn main() {
let s1: str = "Ciao!";
let s2: str = "Come va?";
}
Rust deve sapere quanto spazio allocare per un valore di un qualsiasi type e
tutti i valori di quel type devono occupare la stessa quantità di memoria. Se
Rust ci permettesse di scrivere questo codice, quei due valori str dovrebbero
occupare la stessa quantità di spazio, ma hanno lunghezze diverse: s1
necessita di 5 byte, s2 di 8. Ecco perché non è possibile creare una variabile
contenente un type a dimensione dinamica.
Quindi cosa facciamo? La risposta la conosci già: cambiamo i type di s1 e
s2 in slice di stringa (&str) anziché str. Come detto nella sezione
“Slice di Stringa” del Capitolo 4, la struttura dati slice
memorizza solo l’indirizzo di partenza e la lunghezza della slice. Perciò,
anche se un &T è un singolo valore che memorizza l’indirizzo di memoria di
T, una slice è due valori: l’indirizzo di str e la sua lunghezza. Perciò
sappiamo sempre la dimensione statica di un valore slice stringa durante la
compilazione: è doppia rispetto alla lunghezza di un usize. E quindi,
conosciamo sempre la dimensione di una slice indipendentemente dalla lunghezza
della stringa. In generale, questo è il modo in cui si usano i type a
dimensione dinamica in Rust: hanno un pezzettino di metadati in più per
memorizzare la dimensione dell’informazione dinamica. La regola d’oro dei DST
è che dobbiamo sempre mettere valori di type a dimensione dinamica dietro a
qualche tipo di puntatore.
Possiamo combinare str con tanti type di puntatori: ad esempio, Box<str> o
Rc<str>. È una cosa che hai già visto, ma con un altro type a dimensione
dinamica: i trait. Ogni trait è un type a dimensione dinamica che può
essere indicato usando il nome del trait. Nella sezione “Usare Oggetti
Trait per Astrarre Comportamenti Condivisi” del Capitolo 18 abbiamo menzionato che per usare trait come oggetti
trait dobbiamo metterli dietro a un puntatore, come &dyn Trait o Box<dyn Trait> (anche Rc<dyn Trait> andrebbe bene).
Per lavorare con i DST, Rust fornisce il trait Sized, che determina se la
dimensione di un type è nota a tempo di compilazione. Questo trait è
implementato automaticamente per tutto ciò che ha dimensione nota nel momento
della compilazione. Inoltre, Rust aggiunge implicitamente un vincolo su Sized
per ogni funzione generica: questo vuol dire che la definizione di una funzione
generica come questa:
fn generica<T>(t: T) {
// --taglio--
}
viene trattata come se fosse scritta così:
fn generica<T: Sized>(t: T) {
// --taglio--
}
Per default, le funzioni generiche funzionano solo su type con dimensione nota nel momento della compilazione. Però puoi usare questa sintassi speciale per allentare questa restrizione:
fn generica<T: ?Sized>(t: &T) {
// --taglio--
}
Un vincolo ?Sized significa “T può essere o non essere di dimensione fissa”,
e questa notazione sovrascrive il comportamento predefinito che i generici
debbano avere dimensione nota nel momento della compilazione. La sintassi
?Trait con questo significato è disponibile solo per Sized e non per altri
trait.
Nota anche che abbiamo cambiato il type del parametro t da T a &T.
Poiché il type potrebbe non essere Sized, dobbiamo usarlo dietro a un
qualche tipo di puntatore. In questo caso usiamo un reference.
Ed ora, continuiamo parlando di funzioni e chiusure!