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

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(|| ())
    }
}
Listato 20-25: Uso di un type lungo in molti posti

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(|| ())
    }
}
Listato 20-26: Introduzione di un alias di type, Thunk, per ridurre la ripetizione

Questo 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;
            }
        }
    }
}
Listato 20-27: Un 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!