|
In questo paragrafo parleremo di due costrutti del linguaggio che a volte vengono, in maniera in realtà un po' impropria, considerati identici. Funzioni anonime e chiusure (chiamate lambda in altri linguaggi) hanno molti aspetti in comune e certamente sono strettamente correlate ma presentano anche significative differenze. Possiamo in generale dire che tutte le chiusure sono funzioni anonime, ma non tutte le funzioni anonime sono chiusure. Vediamo quindi quali sono le caratteristiche delle funzioni anonime:
|argomenti | -> eventuale tipo di ritorno { corpo della funzione } La prima, evidente differenza rispetto alle funzioni "normali" è l'uso della coppia di | | invece delle classiche ( ). Vediamo subito un esempio:
Questo semplice programma calcola il doppio del massimo tra due numeri. Alla riga 2 abbiamo la definizione della funzione anonima che ammette due parametri in ingresso e assegna il valore della elaborazione alla variabile ris. Da notare il punto e virgola che chiude la riga 6, il blocco che inizia alla 2 è, nel suo complesso, una istruzione. Un esempio con il valore di ritorno esplicitato è il seguente:
Le differenze rispetto ad una funzione normale in realtà sono abbastanza sottili e lo vediamo da questa semplice comparazione: fn div2 (x: i32) -> i32 { x / 2 } let div2 |x: i32| -> i32 { x / 2 }; nel caso in cui si dichiari il tipo in output le definizioni sono molto simili ma non identiche. Quel let fa capire che creiamo un binding tra un identificatore ed una funzione che di per se è anonima, mentre nel caso della funzione normale, div2 è proprio il nome attribuito alla sequenza di istruzioni. Le funzioni anonime possono acquisire dati dall'ambito in cui si trovano in forma diretta in questo caso si può parlare più direttamente di chiusura. Ecco il motivo principale per cui, come abbiamo detto all'inizio, si può dire quindi che le chiusure sono funzioni anonime ma non tutte le funzioni anonime sono chiusure. Questo fatto, ovvero la capacità di catturare variabili dall'ambiente circostante è vero tratto distintivo. Ma le differenze non finiscono qua:
In questo esempio la variabile x è importata e utilizzata direttamente dalla funzione definita alla riga 3. In questo senso la variabile x è stata solo utilizzate nel siuo valore e quindi nulla la tocca, all'atto pratico. Tuttavia è possibile fare uso della keyword move la quale permette alla chiusura di prendere possesso della variabile. Nel caso in cui questa implementi il trait copy non cambia molto in termini di visibilità, con una stringa ad esempio le cose sono diverse. Si noti infatti la differenza dell'output di questi due semplici programmi: 1) fn main() { let mut num = 5; { let mut add_num = |x: i32| {num += x}; add_num(5); } println!("{}", num ); } 2) fn main() { let mut num = 5; { let mut add_num = move |x: i32| {num += x}; add_num(5); } println!("{}", num ); } L'output del primo è il numero 10, quello del secondo è il numero 5 eppure entrambi si riferiscono alla variabile num. La differenza tra i due quel move evidenziato. In sostanza avviene quanto segue: Senza move: All'interno del blocco { }, viene definita una chiusura add_num che cattura un riferimento mutabile alla variabile num. Quando la chiusura viene chiamata con add_num(5), il riferimento mutabile punta alla stessa variabile num definita nel main. Modificando il valore di num all'interno della chiusura, si modifica la variabile num originale nello scope del main. Alla fine del blocco { }, la chiusura add_num esce dallo scope e il riferimento mutabile non è più valida. Tuttavia, le modifiche apportate a num all'interno della chiusura persistono. println! stampa il valore di num, che è ora 10 (risultato di 5 + 5). Con move: La parola chiave move nella definizione della chiusura indica che la proprietà della variabile num deve essere trasferita alla chiusura. All'interno della chiusura, non si ha più un riferimento mutabile alla variabile num originale, ma bensì una copia del suo valore (in questo caso, 5). Modificando il valore della copia all'interno della chiusura, non si modifica la variabile num originale nell'ambito del main. println! stampa il valore di num, che rimane 5 (poiché la copia della variabile all'interno della chiusura viene modificata non il num originale). Noterete meglio la differenza se aggiungete una funzione di stampa all'interno della chiusura ovvero scrivedo la stessa come segue: let mut add_num = move |x: i32| { num += x; println!("{}", num) }; In sintesi, move consente di trasferire la proprietà di una variabile alla chiusura, isolando le modifiche apportate al suo valore all'interno della chiusura stessa e impedendo che influiscano sulla variabile originale nell'ambito più ampio. Come detto tutto va bene negli esempi precedenti perchè gli interi supportano il trait copy. Se ad esempio usassimo una stringa l'esempio senza move funzionerebbe, quello con move darebbe un errore. Ovvero, per essere precisi: fn main() { let mut ciao = "ciao".to_string(); { let mut cambia = move |x: String| {ciao = x}; cambia("hello".to_string()); } println!("{}", ciao ); } questo non compila. L'esempio in sè non è in realtà significativo dal punto di vista della logica ma rende l'idea di quello a cui si può andare incontro. Scendendo più in profondità, bisogna specificare il ruolo di 3 fondamentali trait quando si parla delle chiusure le quali come abbiamo detto, devono implementarli:
la riga 2 presenta la clausola where che impone che F sia una funzione senza parametri
Se usiamo FnOnce possiamo usare la nostra chiusura solo una volta, come potrete notare se togliete le bare di commento alla riga 14 e provate ad eseguire il programma
Qui invece grazie all'implementazione di FnMut abbiamo la possibilità di cambiare la variabile catturata, ovvero "contatore". La sintassi |_| che trovate ogni tanto nelle chiusure significa semplicemente che la chiusura necessita di un parametro che non sarà usato mentre la sintassi || significa che la chiusura non ha parametri. Le chiusure implementano anche i trait Sized, Clone, Copy, Sync e Send. Il primo in particolare è utile affinchè a compile time abbiamo la conoscenza delle dimensioni richieste dalla chiusura, cosa molto utile ai fini della allocazione di spazio di memoria. Da un punto di vista prestazionale, pur se con qualche piccolo handicap rispetto alle funzioni anonime "pure", le chiusure sono molto ben gestite in Rust. Non allocano mai sullo heap, a meno non che ce le mettiate tramite boxing o altri sotterfugi e sono estremamente efficienti. |