SE VUOI PRENDERE LA CERTIFICAZIONE PER QUESTO CORSO CLICCA QUI
Corso Avanzato sui Tipi in Rust
Il sistema di tipi di Rust ha alcune caratteristiche che finora abbiamo menzionato ma non ancora discusso. Cominceremo parlando dei newtypes in generale, esaminando perché sono utili come tipi. Successivamente passeremo alle alias di tipo, una funzionalità simile ai newtypes ma con una semantica leggermente diversa. Discuteremo anche del tipo ! e dei tipi di dimensione dinamica.
Utilizzo del Modello Newtype per Sicurezza dei Tipi e Astrazione
Il modello newtype è utile anche per compiti oltre a quelli finora discussi, incluso l’impedire staticamente che i valori vengano confusi e indicare le unità di un valore. Hai visto un esempio di utilizzo dei newtypes per indicare unità nel Listato 19-15: ricorda che le strutture Millimeters e Meters incapsulavano valori u32 in un newtype. Se scrivessimo una funzione con un parametro di tipo Millimeters, non potremmo compilare un programma che provasse accidentalmente a chiamare quella funzione con un valore di tipo Meters o un semplice u32.
Possiamo anche utilizzare il modello newtype per astrarre alcuni dettagli di implementazione di un tipo: il nuovo tipo può esporre un’API pubblica diversa dall’API del tipo interno privato.
I newtypes possono anche nascondere l’implementazione interna. Ad esempio, potremmo fornire un tipo People per incapsulare una HashMap<i32, String> che memorizza l’ID di una persona associato al suo nome. Il codice che utilizza People interagirà solo con l’API pubblica che forniamo, come un metodo per aggiungere una stringa di nome alla collezione People; quel codice non dovrà sapere che assegniamo un ID i32 ai nomi internamente. Il modello newtype è un modo leggero per ottenere l’incapsulamento per nascondere i dettagli di implementazione, di cui abbiamo parlato nella sezione “Incapsulamento che Nasconde i Dettagli di Implementazione” del Capitolo 17.
Creazione di Sinonimi di Tipo con Alias di Tipo
Rust fornisce la possibilità di dichiarare un alias di tipo per dare a un tipo esistente un altro nome. Per questo utilizziamo la parola chiave type. Ad esempio, possiamo creare l’alias Kilometers per i32 in questo modo:
rust
type Kilometers = i32;
Ora, l’alias Kilometers è un sinonimo di i32; a differenza dei tipi Millimeters e Meters creati nel Listato 19-15, Kilometers non è un tipo separato e nuovo. I valori che hanno il tipo Kilometers saranno trattati allo stesso modo dei valori di tipo i32.
Poiché Kilometers e i32 sono lo stesso tipo, possiamo aggiungere valori di entrambi i tipi e possiamo passare valori Kilometers a funzioni che prendono parametri i32. Tuttavia, utilizzando questo metodo, non otteniamo i vantaggi del controllo dei tipi che otteniamo dal modello newtype discusso in precedenza. In altre parole, se mischiamo valori Kilometers e i32 da qualche parte, il compilatore non ci darà un errore.
Il principale caso d’uso per i sinonimi di tipo è quello di ridurre la ripetizione. Ad esempio, potremmo avere un tipo lungo come questo:
rust
Box<dyn Fn() + Send + 'static>
Scrivere questo tipo lungo nelle firme delle funzioni e come annotazioni di tipo in tutto il codice può essere noioso e propenso agli errori. Immagina di avere un progetto pieno di codice del genere nel Listato 19-24.
rust
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// --snip--
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// --snip--
}
L’uso di un alias di tipo rende questo codice più gestibile riducendo la ripetizione. Nel Listato 19-25, abbiamo introdotto un alias chiamato Thunk per il tipo verboso e possiamo sostituire tutti gli usi del tipo con l’alias più corto Thunk.
rust
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi")); fn takes_long_type(f: Thunk) {
// --snip--
}
fn returns_long_type() -> Thunk {
// --snip--
}
Questo codice è molto più facile da leggere e scrivere! Scegliere un nome significativo per un alias di tipo può aiutare a comunicare la tua intenzione (thunk è una parola per il codice da valutare in un secondo momento, quindi è un nome appropriato per una chiusura che viene memorizzata).
Gli alias di tipo sono anche comunemente utilizzati con il tipo Result<T, E> per ridurre la ripetizione. Considera il modulo std::io nella libreria standard. Le operazioni di I/O restituiscono spesso un Result<T, E> per gestire le situazioni in cui le operazioni non riescono a funzionare. Questa libreria ha una struct std::io::Error che rappresenta tutti gli errori possibili di I/O. Molte delle funzioni in std::io restituiranno Result<T, E> dove E è std::io::Error, come queste funzioni nel trait Write:
rust
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> viene ripetuto molto. Perciò, std::io ha questa dichiarazione di alias di tipo:
rust
type Result<T> = std::result::Result<T, std::io::Error>;
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 finiscono per assomigliare a questo:
rust
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 tipo aiuta in due modi: rende più facile scrivere il codice e ci dà un’interfaccia coerente in tutto std::io. Poiché è un alias, è solo un altro Result<T, E>, il che significa che possiamo utilizzare tutti i metodi che funzionano su Result<T, E> con esso, oltre alla sintassi speciale come l’operatore ?.
Questi concetti avanzati sui tipi in Rust sono importanti per scrivere codice robusto e comprensibile. Sono utilizzati per garantire la sicurezza dei tipi, semplificare il codice e gestire situazioni in cui le dimensioni dei tipi non sono conosciute a tempo di compilazione.
Corso sui Tipi Speciali in Rust
Il Tipo Mai che Mai Ritorna
In Rust c’è un tipo speciale chiamato ! noto nel linguaggio della teoria dei tipi come il tipo vuoto perché non ha valori. Preferiamo chiamarlo il tipo mai perché sostituisce il tipo di ritorno quando una funzione non tornerà mai. Ecco un esempio:
rust
fn bar() -> ! {
// --snip--
}
Questo codice si legge come “la funzione bar non ritorna mai”. Le funzioni che non ritornano mai sono chiamate funzioni divergenti. Non possiamo creare valori del tipo ! quindi bar non può mai tornare.
Ma a cosa serve un tipo per cui non si possono creare valori? Ricordi il codice dal Listato 2-5, parte del gioco del guessing dei numeri; ne abbiamo riprodotto un po’ qui nel Listato 19-26.
rust
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
A quel tempo, abbiamo saltato alcuni dettagli in questo codice. Nel Capitolo 6 nella sezione “L’operatore di Controllo del Flusso match”, abbiamo discusso che i rami match devono tutti restituire lo stesso tipo. Quindi, ad esempio, il seguente codice non funziona:
rust
// [Questo codice non compila!]
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "ciao",
};
Il tipo di guess in questo codice dovrebbe essere sia un intero che una stringa, e Rust richiede che guess abbia solo un tipo. Quindi cosa restituisce continue? Come ci è stato permesso di restituire un u32 da un ramo e avere un altro ramo che termina con continue nel Listato 19-26?
Come avrai immaginato, continue ha un valore !. Cioè, quando Rust calcola il tipo di guess, guarda sia i rami match, il primo con un valore u32 e il secondo con un valore !. Poiché ! non può mai avere un valore, Rust decide che il tipo di guess è u32.
Il modo formale di descrivere questo comportamento è che le espressioni di tipo ! possono essere convertite in qualsiasi altro tipo. Possiamo terminare questo ramo match con continue perché continue non restituisce un valore; invece, sposta il controllo di nuovo all’inizio del ciclo, quindi nel caso Err, non assegniamo mai un valore a guess.
Il tipo ! è utile anche con la macro panic!. Ricorda la funzione unwrap che chiamiamo sui valori Option<T> per produrre un valore o far panic con questa definizione:
rust
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 che nel match nel Listato 19-26: Rust vede che val ha il tipo T e panic! ha il tipo !, quindi il risultato dell’espressione di match complessiva è T. Questo codice funziona perché panic! non produce un valore; termina il programma. Nel caso None, non restituiremo un valore da unwrap, quindi questo codice è valido.
Un’altra espressione finale che ha il tipo ! è un ciclo:
rust
print!("per sempre ");
loop {
print!("e sempre ");
}
Qui, il ciclo non finisce mai, quindi ! è il valore dell’espressione. Tuttavia, questo non sarebbe vero se includessimo un break, perché il ciclo terminerebbe quando raggiungesse il break.
Tipi di Dimensione Dinamica e il Trait Sized
Rust ha bisogno di conoscere alcuni dettagli sui suoi tipi, come quanto spazio allocare per un valore di un particolare tipo. Questo lascia un angolo del suo sistema di tipi un po’ confuso all’inizio: il concetto di tipi di dimensione dinamica. A volte chiamati DST o tipi non dimensionati, questi tipi ci permettono di scrivere codice utilizzando valori di cui possiamo conoscere la dimensione solo a tempo di esecuzione.
Approfondiamo i dettagli di un tipo di dimensione dinamica chiamato str, che abbiamo usato in tutto il libro. Esatto, non &str, ma str da solo, è un DST. Non possiamo sapere quanto lunga sia la stringa fino al momento dell’esecuzione, il che significa che non possiamo creare una variabile di tipo str, né possiamo prendere un argomento di tipo str. Considera il seguente codice, che non funziona:
rust
// [Questo codice non compila!]
let s1: str = "Ciao!";
let s2: str = "Come va?";
Rust ha bisogno di sapere quanto spazio di memoria allocare per qualsiasi valore di un particolare tipo, e tutti i valori di un tipo devono utilizzare la stessa quantità di memoria. Se Rust ci permettesse di scrivere questo codice, questi due valori str dovrebbero occupare lo stesso spazio. Ma hanno lunghezze diverse: s1 ha bisogno di 12 byte di archiviazione e s2 ne ha bisogno di 15. Ecco perché non è possibile creare una variabile che tenga un tipo di dimensione dinamica.
Quindi cosa facciamo? In questo caso, conosci già la risposta: facciamo diventare i tipi di s1 e s2 un &str invece di un str. Ricordati dalla sezione “Fette di Stringa” del Capitolo 4 che la struttura dati slice memorizza solo la posizione di inizio e la lunghezza della slice. Quindi, anche se un &T è un singolo valore che memorizza l’indirizzo di memoria in cui è situato il T, un &str sono due valori: l’indirizzo del str e la sua lunghezza. Pertanto, possiamo conoscere la dimensione di un valore &str a tempo di compilazione: è il doppio della lunghezza di un usize. Cioè, conosciamo sempre la dimensione di un &str, indipendentemente da quanto lunga sia la stringa a cui si riferisce. In generale, questo è il modo in cui i tipi di dimensione dinamica sono utilizzati in Rust: hanno un bit aggiuntivo di metadati che memorizza la dimensione delle informazioni dinamiche. La regola d’oro dei tipi di dimensione dinamica è che dobbiamo sempre mettere i valori dei tipi di dimensione dinamica dietro un puntatore di qualche tipo.
Possiamo combinare str con tutti i tipi di puntatori: ad esempio, Box<str> o Rc<str>. Infatti, hai visto questo prima ma con un diverso tipo di dimensione dinamica: i trait. Ogni trait è un tipo di dimensione dinamica a cui possiamo fare riferimento utilizzando il nome del trait. Nel Capitolo 17 nella sezione “Utilizzare Oggetti Trait che Consentono Valori di Tipi Diversi”, abbiamo menzionato che per utilizzare i trait come oggetti trait, dobbiamo metterli dietro un puntatore, come &dyn Trait o Box<dyn Trait> (Rc<dyn Trait> funzionerebbe anche).
Per lavorare con i DST, Rust fornisce il Trait Sized per determinare se la dimensione di un tipo è nota a tempo di compilazione. Questo trait viene implementato automaticamente per tutto ciò di cui la dimensione è nota a tempo di compilazione. Inoltre, Rust aggiunge implicitamente un vincolo su Sized a ogni funzione generica. Cioè, una definizione di funzione generica come questa:
rust
fn generica<T>(t: T) {
// --snip--
}
in realtà è trattata come se avessimo scritto questo:
rust
fn generica<T: Sized>(t: T) {
// --snip--
}
Per impostazione predefinita, le funzioni generiche funzioneranno solo su tipi che hanno una dimensione nota a tempo di compilazione. Tuttavia, puoi usare la seguente sintassi speciale per rilassare questa restrizione:
rust
fn generica<T: ?Sized>(t: &T) {
// --snip--
}
Un vincolo di trait su ?Sized significa “T può o non può essere Sized” e questa notazione annulla la predefinizione che i tipi generici devono avere una dimensione conosciuta a tempo di compilazione. La sintassi ?Trait con questo significato è disponibile solo per Sized, non per altri trait.
Nota anche che abbiamo cambiato il tipo del parametro t da T a &T. Poiché il tipo potrebbe non essere Sized, dobbiamo usarlo dietro qualche tipo di puntatore. In questo caso, abbiamo scelto un riferimento.
Dopo, parleremo di funzioni e chiusure!