Home Rhyylen
Contatto
 
 
 
Rust Language
Capitolo 28
Chiusure e funzioni anonime

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:
  • le funzioni anonime non hanno un nome diversamente dalle funzioni normali, come detto
  • le funzioni normali possono avere una ampia visibilità, le funzioni anonime lavorano in un ambito più ritretto
  • le funzioni normali esplicitano il tipo di ritorno, se presente, che nelle funzioni anonime può anche essere dedotto dal corpo
  • le funzioni anonime posso fruire dell'inferenza per le variabili esterne che catturano, quelle normali vogliono specificato il tipo dei parametri.
in generale le funzioni anonime sono brevi e concise, quelle viste nel paragrafo precedente sono preferibili per operazioni più corpose, fermo restano le altre differenze. Interessante è la loro sintassi, significativamente diversa da quelle delle funzioni normali, a partire dalla mancanza della keyword fn (che in realtà possiamo anche usare ma non ce n'è motivo):

 |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:


  Esempio 28.1
1
2
3
4
5
6
7
8
9
10
11
fn main() {
  let calcolo = |x: i32, y: i32| {
    let max = if x > y { x } else { y };
    let ris = max * 2;
     ris
  };
  let a = 5;
  let b = 10;
  let ris = calcolo(a, b);
  println!("Val max doppio tra {} e {} = {}", a, b, ris);
}

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:


  Esempio 28.2
1
2
3
4
5
6
7
fn main() {
  let square = |x: i32| -> i32 {
    x * x
    };
  let result = square(5);
  println!("5^2 = {}", result);
}

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:

  • le chiusure implementano i trait Fn, FnMut ed FnOnce mentre le funzioni anonime non lo fanno, non avendo bisogno di inteloquire con variabili esterne
  • le chiusure possono prendere in prestito (borrow) o spostare (move) variabili dal loro ambiente. Usando move, possiamo forzare la chiusura a prendere ownership delle variabili catturate.
  • le funzioni anonime in compenso sono di norma più leggere, non avendo a che fare con valori esterni.

  Esempio 28.3
1
2
3
4
5
6
fn main() {
    let x = 9;
    let mult = |y: i32| {y * x};
    let res = mult(8);
    println!("x * 9 = {}", res);
}

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:

  • Fn
    Funzioni o chiusure che possono essere chiamate più volte.
    Non modificano lo stato mutabile a cui accedono.
    Rappresentano il trait più generico e sono utilizzate per funzioni che non necessitano di mutare dati esterni.
  • FnMut
    Estende Fn.
    Permette di chiamare la funzione o chiusura più volte.
    Può modificare lo stato mutabile a cui accede.
    Utile per funzioni che necessitano di mutare dati esterni.
  • FnOnce
    Estende Fn.
    Consente di chiamare la funzione o chiusura una sola volta e consumarla.
    Può modificare lo stato mutabile a cui accede.
    Tipicamente utilizzata per operazioni che consumano risorse o modificano lo stato in modo irreversibile.
    Esempio di utilizzo di Fn:
Ora, tutto questo è per ora trasparente e un po' debordante per i nostri scopi, tuttavia credo un po' di cultura di base non faccia male e prepari per ulteriori approfondimenti, oltre a far capire quanto variegato sia  il mondo di Rust. Per questa finalità darò qualche semplice esempio di base, che potrete trovare molto simile anche su altri siti, per far capire di cosa parliamo relativamente ai 3 trait esposti:

  Esempio 28.4
1
2
3
4
5
6
7
8
9
10
11
12
13
fn chiama_fn<F>(chiusura: F)
where F: Fn(),
{
chiusura();
chiusura(); // Può essere chiamata più volte
}

fn main() {
let numero = 10;
let chiusura = || println!("Numero: {}", numero);

chiama_fn(chiusura); // Stampa "Numero: 10" due volte
}

la riga 2 presenta la clausola
where che impone che F sia una funzione senza parametri

  Esempio 28.5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn chiama_fn_once<F>(chiusura: F)
where
F: FnOnce(),
{
chiusura(); // Può essere chiamata una volta sola
}

fn main() {
let stringa = String::from("Ciao, mondo!");
// "move" trasferisce la proprietà a `chiusura`
let chiusura = move || println!("{}", stringa);

chiama_fn_once(chiusura); // Stampa "Ciao, mondo!"
// chiusura();
// Errore! `chiusura` ha già usato `stringa`,
// quindi non può essere chiamata di nuovo.
}

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

  Esempio 28.6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn chiama_fn_mut<F>(mut chiusura: F)
where
F: FnMut(),
{
  chiusura();
  chiusura(); // Può essere chiamata più volte
}

fn main() {
  let mut contatore = 0;
  let mut chiusura = || contatore += 1;
  chiama_fn_mut(chiusura);
// Stampa "Contatore: 2"
  println!("Contatore: {}", contatore);
}

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.