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

Definire un’Enum

Laddove le struct ti danno un modo per raggruppare campi e dati correlati, per esempio un Rettangolo con i propri larghezza e altezza, le enum ti danno un modo per indicare che un valore è uno di un insieme possibile di valori. Per esempio, potremmo voler dire che Rettangolo è una delle possibili forme che include anche Cerchio e Triangolo. Per farlo, Rust ci permette di codificare queste possibilità come un’enum.

Esaminiamo una situazione che potremmo voler esprimere nel codice e vediamo perché le enum sono utili e più appropriati delle struct in questo caso. Supponiamo di dover lavorare con gli indirizzi IP. Attualmente, per gli indirizzi IP si usano due standard principali: versione quattro e versione sei. Poiché queste sono le uniche possibilità di indirizzo IP che il nostro programma incontrerà, possiamo enumerare tutte le varianti possibili, da cui il nome enum.

Qualsiasi indirizzo IP può essere o versione quattro o versione sei, ma non entrambi contemporaneamente. Questa proprietà degli indirizzi IP rende la struttura dati enum appropriata perché un valore di enum può essere solo una delle sue varianti. Sia i versione quattro sia i versione sei sono comunque fondamentalmente indirizzi IP, quindi dovrebbero essere trattati come dati dello stesso type quando il codice andrà a gestire situazioni che si applicano agli indirizzi IP d’ogni genere.

Possiamo esprimere questo concetto nel codice definendo un’enum VersioneIndirizzoIp e elencando le possibili tipologie che un indirizzo IP può essere: V4 e V6. Queste sono le varianti dell’enum:

enum VersioneIndirizzoIp {
    V4,
    V6,
}

fn main() {
    let quattro = VersioneIndirizzoIp::V4;
    let sei = VersioneIndirizzoIp::V6;

    instrada(VersioneIndirizzoIp::V4);
    instrada(VersioneIndirizzoIp::V6);
}

fn instrada(verione_ip: VersioneIndirizzoIp) {}

VersioneIndirizzoIp è ora un type di dato personalizzato che possiamo usare altrove nel nostro codice.

Valori di Enum

Possiamo creare istanze di ciascuna delle due varianti di VersioneIndirizzoIp in questo modo:

enum VersioneIndirizzoIp {
    V4,
    V6,
}

fn main() {
    let quattro = VersioneIndirizzoIp::V4;
    let sei = VersioneIndirizzoIp::V6;

    instrada(VersioneIndirizzoIp::V4);
    instrada(VersioneIndirizzoIp::V6);
}

fn instrada(verione_ip: VersioneIndirizzoIp) {}

Nota che le varianti dell’enum sono nel namespace del suo identificatore, e usiamo il doppio-due punti :: per separarle. Questo è utile perché ora entrambi i valori VersioneIndirizzoIp::V4 e VersioneIndirizzoIp::V6 sono dello stesso type: VersioneIndirizzoIp. Possiamo quindi, per esempio, definire una funzione che accetta qualsiasi VersioneIndirizzoIp:

enum VersioneIndirizzoIp {
    V4,
    V6,
}

fn main() {
    let quattro = VersioneIndirizzoIp::V4;
    let sei = VersioneIndirizzoIp::V6;

    instrada(VersioneIndirizzoIp::V4);
    instrada(VersioneIndirizzoIp::V6);
}

fn instrada(verione_ip: VersioneIndirizzoIp) {}

E possiamo chiamare questa funzione con entrambe le varianti:

enum VersioneIndirizzoIp {
    V4,
    V6,
}

fn main() {
    let quattro = VersioneIndirizzoIp::V4;
    let sei = VersioneIndirizzoIp::V6;

    instrada(VersioneIndirizzoIp::V4);
    instrada(VersioneIndirizzoIp::V6);
}

fn instrada(verione_ip: VersioneIndirizzoIp) {}

L’uso delle enum ha ulteriori vantaggi. Pensando meglio al nostro type per gli indirizzi IP, al momento non abbiamo un modo per memorizzare il vero e proprio indirizzo IP; sappiamo solo di che tipologia si tratta. Dato che hai appena imparato le struct nel Capitolo 5, potresti essere tentato di risolvere questo problema con le struct come mostrato nel Listato 6-1.

fn main() {
    enum VersioneIndirizzoIp {
        V4,
        V6,
    }

    struct IpAddr {
        tipo: VersioneIndirizzoIp,
        indirizzo: String,
    }

    let home = IpAddr {
        tipo: VersioneIndirizzoIp::V4,
        indirizzo: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        tipo: VersioneIndirizzoIp::V6,
        indirizzo: String::from("::1"),
    };
}
Listato 6-1: Memorizzare indirizzo e la variante VersioneIndirizzoIp di un indirizzo IP usando una struct

Qui abbiamo definito una struct IpAddr che ha due campi: un campo tipo di type VersioneIndirizzoIp (l’enum definito prima) e un campo indirizzo di type String. Abbiamo due istanze di questa struct. La prima è home, e ha il valore VersioneIndirizzoIp::V4 come suo tipo con l’indirizzo associato 127.0.0.1. La seconda istanza è loopback. Essa ha l’altra variante di VersioneIndirizzoIp come valore del suo tipo, V6, e ha associato l’indirizzo ::1. Abbiamo usato una struct per raggruppare i valori tipo e indirizzo, così la variante è ora associata al valore.

Tuttavia, rappresentare lo stesso concetto usando solo un’enum è più conciso: invece di un’enum dentro una struct, possiamo mettere i dati direttamente in ogni variante dell’enum. Questa nuova definizione dell’enum IpAddr indica che entrambe le varianti V4 e V6 avranno valori String associati:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

Alleghiamo i dati direttamente a ciascuna variante dell’enum, quindi non c’è bisogno di una struct aggiuntiva. Qui è anche più facile vedere un altro dettaglio di come funzionano le enum: il nome di ciascuna variante che definiamo diventa anche una funzione che costruisce un’istanza dell’enum. Cioè, IpAddr::V4() è una chiamata di funzione che prende un argomento String e ritorna un’istanza del type IpAddr. Otteniamo automaticamente questa funzione costruttrice come risultato della definizione dell’enum.

C’è un altro vantaggio nell’usare un’enum invece di una struct: ogni variante può avere type e quantità diverse di dati associati. Gli indirizzi versione quattro, ad esempio, avranno sempre quattro componenti numeriche con valori tra 0 e 255. Se volessimo memorizzare gli indirizzi V4 come quattro valori u8 ma rappresentare gli indirizzi V6 come una singola String, non potremmo farlo con un struct. Le enum gestiscono questo caso con facilità:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

Abbiamo mostrato diversi modi per definire strutture dati per memorizzare indirizzi IP versione quattro e versione sei. Tuttavia, risulta che voler memorizzare indirizzi IP e codificare di quale tipologia siano è così comune che la libreria standard fornisce una definizione che possiamo usare! Diamo un’occhiata a come la libreria standard definisce IpAddr. Ha l’esatta enum e le varianti che abbiamo definito e usato, ma incapsula i dati dell’indirizzo dentro le varianti sotto forma di due diverse struct, definite in modo differente per ciascuna variante:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --taglio--
}

struct Ipv6Addr {
    // --taglio--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

Questo codice illustra che puoi mettere qualsiasi tipologia di dato dentro una variante di enum: stringhe, type numerici o struct, per esempio. Puoi persino includere un’altra enum! Inoltre, i type della libreria standard spesso non sono molto più complicati di quello che potresti creare tu.

Nota che anche se la libreria standard contiene una definizione per IpAddr, possiamo comunque creare e usare la nostra definizione senza conflitti perché non abbiamo importato la definizione della libreria standard nel nostro scope. Parleremo più avanti dell’importazione dei type nello scope nel Capitolo 7.

Diamo un’occhiata a un altro esempio di enum nel Listato 6-2: questo ha una grande varietà di type incorporati nelle sue varianti.

enum Messaggio {
    Esci,
    Sposta { x: i32, y: i32 },
    Scrivi(String),
    CambiaColore(i32, i32, i32),
}

fn main() {}
Listato 6-2: Un’enum Messaggio le cui varianti memorizzano ciascuna quantità e type diversi di valori

Questa enum ha quattro varianti con type diversi:

  • Esci: non ha dati associati
  • Muovi: ha campi nominati, come fa un struct
  • Scrivi: include una singola String
  • CambiaColore: include tre valori i32

Definire un’enum con varianti come quelle nel Listato 6-2 è simile a definire diversi type di struct, eccetto che l’enum non usa la parola chiave struct e tutte le varianti sono raggruppate sotto il type Messaggio. Le seguenti struct potrebbero contenere gli stessi dati che le varianti dell’enum precedente contengono:

struct MessaggioEsci; // unit struct
struct SpostaMessaggio {
    x: i32,
    y: i32,
}
struct ScriviMessaggio(String); // tuple struct
struct CambiaColoreMessaggio(i32, i32, i32); // tuple struct

fn main() {}

Ma se usassimo le diverse struct, ognuna con il proprio type, non potremmo definire altrettanto facilmente una funzione che accetti uno qualsiasi di questi type di messaggi come potremmo fare con l’enum Messaggio definito nel Listato 6-2, che è un singolo type.

C’è un’ulteriore somiglianza tra enum e struct: proprio come possiamo definire metodi sulle struct usando impl, possiamo anche definire metodi sulle enum. Ecco un metodo nominato chiama che potremmo definire sulla nostra enum Messaggio:

fn main() {
    enum Messaggio {
        Esci,
        Sposta { x: i32, y: i32 },
        Scrivi(String),
        CambiaColore(i32, i32, i32),
    }

    impl Messaggio {
        fn chiama(&self) {
            // il corpo del metodo sarà definito qui
        }
    }

    let m = Messaggio::Scrivi(String::from("ciao"));
    m.chiama();
}

Il corpo del metodo userebbe self per ottenere il valore su cui abbiamo chiamato il metodo. In questo esempio, abbiamo creato una variabile m che ha il valore Messaggio::Scrivi(String::from("ciao")), e quello sarà self nel corpo del metodo chiama quando m.chiama() viene eseguito.

Diamo un’occhiata a un’altra enum nella libreria standard che è molto comune e utile: Option.

L’Enum Option

Questa sezione esplora un caso di studio su Option, che è un’altra enum definito dalla libreria standard. Il type Option codifica lo scenario molto comune in cui un valore può essere qualcosa oppure niente.

Per esempio, se richiedi il primo elemento di una lista non vuota, otterrai un valore. Se richiedi il primo elemento di una lista vuota, non otterrai niente. Esprimere questo concetto in termini del sistema dei type permette al compilatore di verificare se hai gestito tutti i casi che dovresti gestire; questa funzionalità può prevenire bug estremamente comuni in altri linguaggi di programmazione.

La progettazione dei linguaggi di programmazione è spesso pensata in termini delle funzionalità che includi, ma anche le funzionalità che escludi sono importanti. Rust non prevede l’uso di null che molti altri linguaggi possiedono. Null è un valore che significa che non c’è alcun valore. Nei linguaggi con null, le variabili possono essere sempre in uno dei due stati: null o non-null.

Nella sua presentazione del 2009 “Null References: The Billion Dollar Mistake”, Tony Hoare, l’inventore del null, disse:

Lo chiamo il mio errore da un miliardo di dollari. All’epoca stavo progettando il primo sistema di type completo per i reference in un linguaggio orientato agli oggetti. Il mio obiettivo era garantire che ogni uso dei reference fosse assolutamente sicuro, con i controlli effettuati automaticamente dal compilatore. Ma non ho resistito alla tentazione di inserire un reference nullo, semplicemente perché era così facile da implementare. Questo ha portato a innumerevoli errori, vulnerabilità e crash di sistema, che probabilmente hanno causato un miliardo di dollari di dolore e danni negli ultimi quarant’anni.

Il problema con i valori null è che se provi a usare un valore null come se fosse un valore non-null, otterrai un errore di qualche tipo. Poiché questa proprietà null o no-null è pervasiva, è estremamente facile commettere questo tipo di errore.

Tuttavia, il concetto che il null cerca di esprimere è ancora utile: null è un valore che è attualmente invalido o assente per qualche motivo.

Il problema non è veramente il concetto ma l’implementazione. Di conseguenza, Rust non ha i null, ma ha un’enum che può codificare il concetto di un valore presente o assente. Questa enum è Option<T>, ed è definito dalla libreria standard come segue:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

L’enum Option<T> è così utile che è incluso nel prelude (preludio è l’insieme di funzionalità che Rust include di default in ogni programma fornite dalla libreria standard); non è necessario portarlo nello scope esplicitamente. Le sue varianti sono anch’esse incluse nel prelude: puoi usare Some e None direttamente senza il prefisso Option::. L’enum Option<T> è comunque un normale enum, e Some(T) e None sono ancora varianti del type Option<T>.

La sintassi <T> è una caratteristica di Rust di cui non abbiamo ancora parlato. È un parametro di type generico, e tratteremo i type generici più in dettaglio nel Capitolo 10. Per ora, tutto ciò che devi sapere è che <T> significa che la variante Some dell’enum Option può contenere un pezzo di dato di qualsiasi type, e che ogni type concreto che viene usato al posto di T rende il type complessivo Option<T> un type diverso. Ecco alcuni esempi di utilizzo di valori Option per contenere type numerici e type carattere:

fn main() {
    let un_numero = Some(5);
    let un_carattere = Some('e');

    let nessun_numero: Option<i32> = None;
}

Il type di un_numero è Option<i32>. Il type di un_carattere è Option<char>, che è un type diverso. Rust può inferire questi type perché abbiamo specificato un valore dentro la variante Some. Per nessun_numero, Rust ci richiede di annotare il type di Option: il compilatore non può inferire il type che la variante Some corrispondente conterrà guardando solo al valore None. Qui diciamo a Rust che intendiamo che nessun_numero sia di type Option<i32>.

Quando abbiamo un valore Some, sappiamo che un valore è presente e il valore è contenuto all’interno di Some. Quando abbiamo un valore None, in un certo senso significa la stessa cosa di null: non abbiamo un valore valido. Quindi perché avere Option<T> è meglio che avere null?

In breve, perché Option<T> e T (dove T può essere qualsiasi type) sono type diversi, il compilatore non ci permetterà di usare un valore Option<T> come se fosse sicuramente un valore valido. Per esempio, questo codice non compilerà, perché sta cercando di sommare un Option<i8> a un i8:

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let somma = x + y;
}

Se eseguiamo questo codice, otteniamo un messaggio di errore come questo:

$ cargo run
   Compiling enums v0.1.0 (file:///progetti/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:6:19
  |
6 |     let somma = x + y;
  |                   ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
  = help: the following other types implement trait `Add<Rhs>`:
            `&i8` implements `Add<i8>`
            `&i8` implements `Add`
            `i8` implements `Add<&i8>`
            `i8` implements `Add`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error

Wow, quanta roba! Nel concreto, questo messaggio di errore significa che Rust non capisce come sommare un i8 e un Option<i8>, perché sono type diversi. Quando abbiamo un valore di un type come i8 in Rust, il compilatore deve assicurarsi che abbiamo sempre un valore valido. Possiamo procedere con fiducia senza dover controllare il null prima di usare quel valore. Solo quando abbiamo un Option<i8> (o qualunque type di valore con cui stiamo lavorando) dobbiamo preoccuparci della possibilità di non avere un valore, e il compilatore ci assicurerà di gestire quel caso prima di usare il valore.

In altre parole, devi convertire un Option<T> in un T prima di poter eseguire operazioni su T. Generalmente, questo aiuta a catturare uno dei problemi più comuni del null: presumere che qualcosa non sia null quando in realtà lo è.

Eliminare il rischio di presumere erroneamente un valore non-null ti aiuta a essere più sicuro nel tuo codice. Per avere un valore che può essere eventualmente null, devi esplicitamente optare per questo facendo sì che il type di quel valore sia Option<T>. Poi, quando usi quel valore, sei obbligato a gestire esplicitamente il caso in cui il valore sia null. Ovunque un valore abbia un type che non è Option<T>, puoi assumere in sicurezza che il valore non sia null. Questa è stata una decisione di design voluta in Rust per limitare la pervasività del null e aumentare la sicurezza del codice Rust.

Quindi come si estrae il valore T da una variante Some quando si ha un valore di type Option<T> in modo da poter usare quel valore? L’enum Option<T> ha un gran numero di metodi utili in varie situazioni; puoi consultarli nella sua documentazione. Familiarizzare con i metodi su Option<T> sarà estremamente utile nel tuo percorso con Rust.

In generale, per usare un valore Option<T> devi avere codice che gestisca ogni variante. Vuoi del codice che venga eseguito solo quando hai un valore Some(T), e quel codice può usare il T interno. Vuoi altro codice che venga eseguito solo se hai un valore None, e quel codice non ha un valore T disponibile. L’espressione match è un costrutto di controllo del flusso che fa esattamente questo quando viene usata con le enum: eseguirà codice diverso a seconda di quale variante dell’enum ha, e quel codice può usare i dati all’interno del valore che corrisponde.