Pattern matching

Una operazione utile sui tipi enumerazioni è quella di controllare quale delle varianti sia un valore, così da poter modificare il comportamento del programma. Ad esempio per calcolare la lunghezza di un vettore, dobbiamo fare due calcoli differenti quando è espresso in coordinate cartesiane e in coordinate polari. Questo è esattamente quello che ci permette di fare il costrutto match:

enum Vector {
    Cartesian(f32, f32),
    Polar(f32, f32)
}

impl Vector {
    fn length(&self) -> f32 {
        match self {
            Vector::Cartesian(x, y) => (x*x + y*y).sqrt(),
            Vector::Polar(r, _a) => *r
        }
    }
}

fn main() {
    let v1 = Vector::Cartesian(3.0, 2.0);
    let v2 = Vector::Polar(4.2, 1.0);
    println!("Lunghezza di v1: {}", v1.length());
    println!("Lunghezza di v2: {}", v2.length());
}

L'output del programma è

Lunghezza di v1: 3.6055512
Lunghezza di v2: 4.2

Possiamo fare delle osservazioni sul codice sopra:

  • la funzione length è inserita all'interno di un blocco impl, infatti è possibile definire dei metodi anche per le enumerazioni. Grazie a questo, possiamo chiamare la funzione length con la sintassi di un metodo.
  • Il costrutto match esegue appunto il cosiddetto "pattern matching", ovvero prova a eguagliare il valore target con una struttura, legando i valori interni. Per esempio il primo ramo indica che stiamo cercando un valore del tipo Vector::Cartesian, con valori interni x e y. All'interno della espressione possiamo usare questi valori interni, che sono in un certo senso "estratti" dalla struttura.
  • Nel secondo ramo, siccome stiamo utilizzando solamente r, il parametro a è prefissato con un underscore per indicare che è intenzionalmente non usato nell'espressione. Il compilatore Rust, infatti, restituisce un warning ogni volta che una variabile non viene usata.
  • L'intero costrutto match ritorna un valore, quindi possiamo omettere i comandi return.

Possiamo sostituire le espressioni interne con dei blocchi, qualora volessimo eseguire dei comandi

enum Vector {
    Cartesian(f32, f32),
    Polar(f32, f32)
}

impl Vector {
    fn length(&self) -> f32 {
        match self {
            Vector::Cartesian(x, y) => {
                println!("Il vettore è espresso in coordinate cartesiane");
                (x*x + y*y).sqrt()
            },
            Vector::Polar(r, _a) => {
                println!("Il vettore è espresso in coordinate polari");
                *r
            }
        }
    }
}

fn main() {
    let v1 = Vector::Cartesian(3.0, 2.0);
    let v2 = Vector::Polar(4.2, 1.0);
    println!("Lunghezza di v1: {}", v1.length());
    println!("Lunghezza di v2: {}", v2.length());
}

L'output del programma è il seguente.

Il vettore è espresso in coordinate cartesiane
Lunghezza di v1: 3.6055512
Il vettore è espresso in coordinate polari
Lunghezza di v2: 4.2

Il costrutto if-let

A volte vogliamo fare matching solo di una delle possibili varianti di una enumerazione, questo è proprio quello che il costrutto if let ci permette di fare. Ad esempio supponiamo di avere una enumerazione per dei comandi, e vogliamo controllare se la moneta è un centesimo, potremmo usare match come sopra, ma esiste una maniera più concisa.

enum Request {
    Exit,
    GetPage(String),
    GetValueAtPosition { x: u32, y: u32 },
    SetBackgroundColor(u8, u8, u8)
}

fn get_request() -> Request {
    Request::Exit
}

// [...]

fn main() {
    let req = get_request();
    if let Request::Exit = req {
        println!("Exited");
    } 
}

Possiamo anche destrutturare i valori interni, come nel costrutto match, per poi usarli all'interno del corpo dell'if.

enum Request {
    Exit,
    GetPage(String),
    GetValueAtPosition { x: u32, y: u32 },
    SetBackgroundColor(u8, u8, u8)
}

fn get_request() -> Request {
    Request::Exit
}

fn main() {
    let req = get_request();
    if let Request::GetPage(page) = req {
        println!("GetPage request for {}", page);
    } 
}

Il pattern matching è una feature molto potente di Rust, e ha molte altre funzionalità rispetto a quelle discusse qua. Queste si possono trovare nella documentazione ufficiale del linguaggio.

L'enumerazione Option

Una delle enumerazioni più utili e usate nella libreria standard di Rust è l'enumerazione Option. Questa ha solo due varianti: Some, che contiene un valore e None che non contiene nessun valore. Può essere pensata come un valore che può essere opzionalmente null. Rust non supporta valori vuoti come null o undefined, ma richiede esplicitamente di incapsulare ogni valore che potrebbe essere vuoto all'interno di una Option. Questa enumerazione è generica, intuitivamente vuol dire che Some può contenere un valore di qualsiasi tipo, basta che sia marcato nella sua definizione di tipo. Per esempio Option<u32> può contenere un u32, e una Option<String> può contenere una stringa. Vedremo i tipi generici nel prossimo capitolo.

Supponiamo di voler implementare una funzione che ritorna il primo elemento di un array, questo potrebbe non esistere se l'array è vuoto. Possiamo però ritornare una Option:

  • se la lista è vuota ritorna None
  • altrimenti ritorna il primo elemento incapsulato in Some.

Una possibile implementazione è la seguente.

fn first_value(arr: &[i32]) -> Option<i32> {
    if arr.is_empty() {
        None
    } else {
        Some(arr[0])
    }
}

fn main() {
    let arr = [1, 2, 3];
    match first_value(&arr) {
        Some(value) => println!("Il primo valore è {}", value),
        None => println!("L'array è vuoto"),
    }
}

La gestione esplicita di valori vuoti semplifica i programmi e li rende meno proni a errori, in quanto siamo forzati dal sistema di tipi a gestire esplicitamente le condizioni in cui un valore è vuoto, oppure le condizioni di errore.