Rust - Selezione con if-else


Con questo paragrafo interrompiamo l'analisi dei tipi e, anche in preparazione all'importante argomento che sono le funzioni, affrontiamo una delle istruzioni più note nell'intero mondo della programmazione. Si tratta della modalità più semplice per effettuare una selezione durante il flusso di esecuzione di un programma. Praticamente tutti i linguaggi prevedono questo tipo di costrutto, molto comodo, utile e di chiaro significato.

Stiamo parlando di una istruzione di selezione caratterizzata dalle keyword if ed else e che si può presentare nelle seguenti 3 forme:

1)

if condizione {
  istruzioni
}

2)

if condizione  {
  istruzioni
}
else {
  istruzioni
}

3)

if condizione {
  istruzioni
}
else if condizione {
  istruzioni
}
....
else {
  istruzioni
}

laddove la "condizione" è in realtà una espressione booleana che può quindi assumere le due forme classiche true o false. Diversamente da altri linguaggi la coppia di parentesi graffe è necessaria anche se il blocco istruzioni fosse composto da una sola di esse, scelta che condivido. Vediamo una serie di esempi che mostrano la progressione dei 3 punti evidenziati, iniziando dal punto 1)

Esempio 18.1

use std::io;
use std::io::prelude::*;
fn main(){
    print!( "Inserisci un numero: ");
    io::stdout().flush().ok().expect("");
    let mut input01 = String::new();
    let _ = io::stdin().read_line(&mut input01);
    let x01: i32 = input01.trim().parse()
    .ok()
    .expect("Voglio un numero!");
    if x01 > 10 {
        print!("Inserito numero > 10");
    }
}

Questo primo caso ci presenta l'inserimento di input da parte dell'utente (parleremo diffusamente dell'input... non è così banale come in altri linguaggi, ma Rust è Rust) e se il numero inserito è maggiore di 10 avremo un output, altrimenti il programma termina.
Passiamo ora al punto 2):

Esempio 18.2

use std::io;
use std::io::prelude::*;
fn main(){
    print!( "Inserisci un numero: ");
    io::stdout().flush().ok().expect("");
    let mut input01 = String::new();
    let _ = io::stdin().read_line(&mut input01);
    let x01: i32 = input01.trim().parse()
    .ok()
    .expect("Please type a number!");
    if x01 < 10
    {
        print!("Inserito numero < 10");
    }
    else
    {
        print!("Inserito un numero >= 10");
    }
}

Abbastanza semplice: in questo caso il primo ramo gestisce i valori minori di 10, il secondo, quello che inizia con else, ci dà un output con i numeri maggiori o uguali a 10.
E veniamo al punto 3):

Esempio 18.3

use std::io;
use std::io::prelude::*;
fn main(){
    print!( "Inserisci un numero: ");
    io::stdout().flush().ok().expect("");
    let mut input01 = String::new();
    let _ = io::stdin().read_line(&mut input01);
    let x01: i32 = input01.trim().parse().ok()
    .expect("Please type a number!");
    if x01 <= 10 {
        print!("Nido o asilo o le elementari");
    }
    else if x01 > 10 && x01 < 14 {
        print!("Vai alla scuola secondaria");
    }
    else if x01 >= 14 && x01 < 18 {
        print!("Sei alle superiori?");
    }
    else {
        print!("O sei all'UNI o hai finito la scuola");
    }
}

Qui abbiamo 4 ramificazioni, la prima per i numeri minori o uguali a 10, la seconda per i numero tra 11 e 13, la terza tra 14 e 17 infine l'ultimo, else senza condizioni, tutti i numeri superiori, in pratica fa da rastrello.

Potete inserire tante ramificazione else + if quante ne volete, tuttavia è buona norma non esagerare con il loro numero, se dovete considerare molte condizioni alternative sarà bene usare altri costrutti, tipo il pattern matching che vedremo in altro paragrafo. Ovviamente, bisogna scegliere con attenzione i valori o le casistiche che costituiscono i criteri di selezione, lo dico anche se è banale, lo so, perchè si vedono errori che fanno perdere tempo inutile anche per cose semplici come la manipolazione di questi costrutti.

Quello che invece è importante ricordare è che verrà eseguito il ramo corrispondente alla prima condizione soddisfatta, tutte quelle successive, anche se fossero soddisfatte (ma se le selezioni sono fatte come si deve questo di norma non deve accadere), verranno saltate. Due rami non potranno essere eseguiti.

Ma c'è di più, come detto Rust è un linguaggio expression oriented, ogni espressione esprime un valore che potrà essere reale o anche il semplice unit (). Questo vale anche per il nostro selettore if + else, quindi possiamo abbinarlo ad una variabile, con le consuete cautele di congruenza valori / tipo. Vediamo come:

Esempio 18.4

use std::io;
use std::io::prelude::*;
fn main(){
    print!( "Inserisci un numero: ");
    io::stdout().flush().ok().expect("");
    let mut input01 = String::new();
    let _ = io::stdin().read_line(&mut input01);
    let x01: i32 = input01.trim().parse()
    .ok()
    .expect("Please type a number!");
    let y = if x01 % 2 == 0 {"pari"} else {"dispari"};
    print!("{}", y);
}

Come avrete capito l'istruzione "core" del nostro programma è:
let y = if x01 % 2 == 0 {"pari"} else {"dispari"};
la quale assegna alla variabile y il valore in uscita dalla selezione che la segue, una stringa che indica se il numero inserito dall'utente è pari o dispari.
La natura espressiva di questo costrutto è tale che potrete anche scrivere cose tipo:

println!("{}", if x == 0 { "Ciao" }
else {"Boh"});

attribuendo direttamente alla funzione di stampa il risultato della selezione.
Naturalmente nei casi di attribuzione del risultato di una selezione ad una variabile deve essere conservata la compatibilità dei tipi che esprimete verso l'esterno, ovvero non potete scrivere cose del tipo:

let x = if condizione {
    42 // intero
} else {
    "ciao" // stringa
};

in quanto un ramo esprime un intero e un altro una stringa.

Una particolarità, che poi in pratica di solito è un errore, è quello di far terminare un branch con il ; in Rust l'ultimam espressione senza il ; restituisce un valore altrimenti abbiamo ( ). Ovvero:

let x = if 2 < 3 {
5;
} else {
10;
}; // x = ()

x non vale 5 ma ( ).

Un piccola carenza, peraltro non grave, del costrutto if in Rust rispetto ad altri linguaggi è la mancanza dell'operatore ternario. Un esempio rapido per chiarire può essere il seguente:

In C (e in altri linguaggi similmente) possiamo scrivere:
int x = (a > b) ? a : b;

In Rust l'equivalente obbliga l'uso di else:
let x = if a > b { a } else { b };

Molto interessante è la sequenza if - let. Questa permette di usare insieme un controllo di selezione con un pattern matching e quindi eseguire una qualche azione sulla base del confronto effettuato. Risulta utile quando ci serve per esempio lavorare solo su una parte ad esempio di una ennupla e di un enumerativo e non abbiamo bisogno di effettuare un match su tutti gli elementi (vedere appunto il paragrafo del pattern matching). In pratica può sostituire un pattern matching quando ci interessa un solo caso evitando la verbosità dell'altro costrutto che obbliga (e in realtà è un punto di forza che viene utile in altre situazioni) alla copertura completa dei casi. Un semplice esempio potrà chiarire il concetto. Anticipiamo il concetto di Option che eventualmente potete consultare:

Esempio 18.5

fn main(){
let numero: Option = Some(7);
// Con match, obbligo di gestire ENTRAMBI i casi
match numero {
    Some(n) => println!("Numero = {}", n),
    None => (), // caso vuoto, ma dobbiamo comunque occuparcene
  }
}

Come vedremo il potente tipo Option oltre a darci una grande opportunità di gestire valori nulli ci obbliga anche a trattarli, cioè a gestire la loro presenza. Ma nel caso in cui null non sia previsto ecco che if-let ci dà una opportunità di alleggerire il codice:

let numero: Option<i32> = Some(7);
if let Some(n) = numero {
  println!("Il numero è {}", n);
}

In questo caso non abbiamo necessità di valutare il caso null in quanto sappiamo che abbiamo un valore certo, il numero 7. Questo ci permette una codifica più agile e senza ridondanze. Questa sequenza è molto usata e la incontreremo spesso in seguito. La versione 1.88 del linguaggio ha stabilizzato anche il supporto ai let chains: la possibilità di combinare più condizioni let dentro un if, con l'operatore &&:

if let Some(x) = qualcosa && let Ok(y) = altra_cosa(x) {
// x e y sono entrambi disponibili qui
}

Anche in questo caso a giovarsene è la leggibilità e la manutenibilità del codice (e, lo dico piano, di semplicità Rust ne ha un po' bisogno)

A partire da Rust 1.65 è stato stabilizzato anche let - else che risolve un problema diciamo opposto. Se ne può fare a meno, nel senso che si può aggirare il suo uso che però risulta comodo e quindi perchè non usarlo? Vediamo il seguente codice:

fn processa(input: Option<i32>) -> i32 {
  if let Some(n) = input {
  // il resto della funzione è tutto qui dentro,
  // sempre più annidato...
    n * 2
    } else {
      return -1;
  }
}

Abbiamo una funzione che gestisce un parametro in input. Successivamente lo lavora tramite if let ed eventualmente se non viene soddisfatta la condizione si passa al else che restituisce in questo caso un valore che potrebbe significare un qualche tipo di anomalia. Funziona certo, in questo senso non c'è bisogno di altro ma chiaramente tutto lo sviluppo di if-let avviene ad un livello più interno. Esiste la possibilità di lavorare al livello più così da migliorare la leggibilità del programma:

fn processa(input: Option<i32>) -> i32 {
  let Some(n) = input else {
    return -1;
  };
// n è disponibile qui, allo stesso livello di indentazione
n * 2
}

quindi eseguiamo else se non è verificata la condizione mentre diversamente siamo sempre a livello più alto e non annidato come nel caso precedente all'interno del if. In casi più complessi può in effetti essere un aiuto a livello di comprensione e manutenibilità del codice.

A dimostrazione di come Rust evolva di versione in versione c'è anche una novità introdotta nella release 1.95. Dal momento che coinvolge il pattern matching ne parleremo però in quel contesto. Proprio la collaborazione con il pattern matching e con le funzioni, come sempre, costituisce un'altra parte che andrà approfondita con attenzione.