Abbiamo già sperimentato le funzioni in Rust e visto quale sia il ruolo centrale che rivestono nella programmazione. Essenzialmente, con esse possiamo incapsulare del codice, assegnargli un nome, definire parametri e tipi di ritorno ed invocare tutto in base alle nostre necessità. Rivolgendoci però ad una programmazione più avanzata ma anche dall'approccio più flessibile e, per così dire, "funzionale" è arrivato il momento di parlare di closure.
Closure: definizione e sintassi
Le closure sono funzioni anonime che possono essere assegnate a variabili o passate direttamente ad una funzione come argomento.
Da un punto di vista sintattico, le closure sono caratterizzate da un doppio pipe ||
all'interno del quale possiamo inserire (qualora ve ne siano) parametri in input. Questa notazione viene seguita da un blocco di codice che rappresenta la funzionalità che deve essere eseguita al momento dell'invocazione della closure.
Gli esempi del prossimo paragrafo chiariranno meglio le idee.
Qualche esempio pratico
Diciamo che vogliamo creare una closure che svolga il ruolo di restituire sempre 2 come risultato. Ciò probabilmente da un punto di vista pratico non sarebbe mai utile ma lo useremo come punto zero per iniziare la nostra carrellata di esempi:
let restituisce_due=|| 2;
println!("Risultato della closure: {}", restituisce_due());
Eseguito tale codice viene stampato in output: Risultato della closure: 2. Tecnicamente, l'espressione || 2
significa che:
- abbiamo definito una closure e per fare ciò è sufficiente usare il doppio pipe;
- abbiamo specificato il codice da eseguire che non è altro che la restituzione del valore 2.
Supponiamo che ora si voglia creare una closure in grado di avere un comportamento dinamico in base ad un parametro in ingresso. Le viene passato un valore ed essa ne restituisce il doppio.
let restituisce_il_doppio=|x| x*2;
println!("Risultato della closure: {}", restituisce_il_doppio(7));
In questo caso, vediamo che il parametro in input (potrebbero essere più di uno) viene passato tra le pipe ed il codice che segue implementa il prodotto.
Il messaggio che viene stampato è Risultato della closure: 14. Per la variabile x
non abbiamo specificato il tipo di dato e potremmo, per questo, passare altri valori. Purché non in conflitto con la moltiplicazione per l'intero 2.
Tuttavia, si può fare in modo che x
possa essere solo un intero senza segno e che anche il risultato lo sia. Questo ci dà uno spunto per sperimentare come i tipi di dato possono essere specificati in una closure:
let restituisce_il_doppio=|x:usize|->usize {x*2};
Questa seconda versione, ad esempio, accetterebbe 7 come parametro in input ma non un numero negativo.
Closure e parametri multipli
Una closure può anche accettare più parametri come nella seguente, finalizzata al calcolo del prodotto:
let restituisce_il_prodotto=|x,y| x*y;
println!("Risultato della closure: {}", restituisce_il_prodotto(6,9));
Questa invocazione stampa Risultato della closure: 54. Dimostrando che ha perfettamente funzionato eseguendo il codice sui due parametri forniti.
Estendere il codice
Ultimo elemento per imparare a configurare sintatticamente una closure. Troviamo la possibilità di estendere il codice creandone un blocco tra parentesi graffe. Nel seguente esempio creiamo una clousure che accetti solo numeri naturali per calcolare un fattoriale:
let restituisce_il_fattoriale=|x:usize| {
let mut res=1;
for v in (1..=x)
{res*=v}
res
};
println!("Fattoriale: {}", restituisce_il_fattoriale(6));
Viene calcolato il fattoriale del numero 6 ed otteniamo - correttamente - il risultato 720. Come vediamo, tutto il codice è stato racchiuso tra parentesi graffe, aspetto reso necessario dalla sua lunghezza, e abbiamo potuto specificare un parametro in ingresso di cui abbiamo indicato il tipo di dato.
Passare una closure come argomento
Un aspetto molto interessante delle closure consiste nella loro capacità di essere passate ad una funzione come se fossero un normale valore. In questo modo, potranno essere eseguite all'interno della funzione che le riceve.
Un tipico caso è quello dell'elaborazione di vettori mediante iteratori.
Abbiamo già visto che possiamo "iterare" su strutture dati con il metodo iter
ma in questo esempio scopriremo che possiamo anche attivare funzionalità su ogni elemento su cui iteriamo. Infatti con map
possiamo applicare una trasformazione su un singolo elemento e poi raccogliere i risultati di ogni elaborazione con collect
.
Ma ci chiediamo: come possiamo fare ad indicare al map
quali operazioni di trasformazione eseguire? Ci vorrebbe del codice da inviargli ed è per questo che gli "spediamo", ad ogni invocazione, la closure da eseguire. Ecco un esempio.
Abbiamo un vettore di numeri interi e vogliamo che ognuno di essi diventi l'input di un'invocazione della closure |valore| valore*2
in modo che dal procedimento esca un vettore di valori. Ognuno doppio, rispetto a quelli entrati:
let numeri: Vec<i32> = vec![7, 4, 14, 23, 12];
let doppi: Vec<_> = numeri.iter().map(|valore| valore*2).collect();
Il contenuto di doppi
, dopo l'esecuzione, sarà [14, 8, 28, 46, 24]
. Il valore di tutto ciò consiste nel fatto che il map
rappresenta un meccanismo generico e passandogli una closure lo possiamo personalizzare ad ogni invocazione.