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 per 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 possiamo passare valori di type Chilometri
a funzioni
che accettano parametri i32
. Tuttavia, usando questo metodo non otteniamo i
benefici di controllo dei type che otteniamo 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!("hi")); 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 continue
All’epoca abbiamo trascurato alcuni dettagli di questo codice. In “Controllare
il Flusso con il costrutto match
” nel
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
incontrasse 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 12 byte, s2
di 15. Ecco perché non è possibile creare una
variabile di type str
.
Quindi cosa facciamo? La risposta la conosci già: cambiamo i type di s1
e
s2
da str
a &str
. Ricorda da “Slice di Stringa” nel
Capitolo 4 che 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
, un &str
è due valori: l’indirizzo
di str
e la sua lunghezza. Perciò sappiamo sempre la dimensione statica di un
valore &str
: è doppia rispetto alla lunghezza di un usize
. E quindi,
conosciamo sempre la dimensione di una &str
indipendentemente dalla lunghezza
della stringa. In generale, questo è il modo in cui si usano i type
dimensionati dinamicamente 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 dimensionato dinamicamente dietro
a qualche tipo di puntatore.
Possiamo combinare str
con tanti type di puntatori: ad esempio, Box<str>
o
Rc<str>
. Hai già visto questo ma con un altro type a dimensione dinamica: i
trait. Ogni trait è un type dimensionato dinamicamente che può essere
indicato usando il nome del trait. In “Usare Oggetti Trait per Astrarre
Comportamenti Condivisi” nel 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 a
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 a tempo di 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 no di dimensione fissa” e questa
notazione sovrascrive il comportamento predefinito che i generici debbano avere
dimensione nota a tempo di 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!