Cos’è una closure in JavaScript?
| |
makeCounter viene eseguita una volta, restituisce una funzione, e la sua chiamata finisce lì: lo stack frame non esiste più. Eppure ogni chiamata a counter() legge e aggiorna count, una variabile dichiarata dentro una funzione già terminata. count non torna a 0, e non viene copiata. È la stessa variabile, ancora viva, tre chiamate dopo.
Questa è una closure: una funzione legata a un riferimento alle variabili che erano nello scope dove è stata definita. Non una fotografia dei loro valori: un riferimento vivo. Se qualcos’altro modifica count, il counter vede il nuovo valore.
Perché la variabile sopravvive dopo che la funzione termina?
JavaScript risolve i nomi delle variabili lessicalmente, in base a dove sono scritte nel codice, non a chi chiama la funzione. Per count += 1 dentro la funzione interna, il motore cerca negli scope che la racchiudono finché non trova una dichiarazione, e trova count in makeCounter. Quel collegamento è fissato dove la funzione interna è definita, non da dove viene chiamata.
Normalmente, una volta che una funzione termina, niente può raggiungere le sue variabili locali e il garbage collector le recupera. Una closure è l’eccezione. La funzione restituita mantiene un riferimento allo scope in cui è stata creata, quindi quello scope resta vivo tanto quanto la funzione. Chiama makeCounter() di nuovo e ottieni un secondo counter con un proprio count in un proprio scope:
| |
Ogni funzione che crei in JavaScript chiude sul proprio scope circostante, che tu lo usi o no. Di solito niente sopravvive alla chiamata che l’ha creata, quindi non te ne accorgi mai. Conta quando restituisci la funzione interna, la passi altrove, o la memorizzi: continua a funzionare anche dopo che lo scope esterno sarebbe altrimenti sparito.
Come si creano variabili private con una closure?
Il counter mostra già l’uso principale: dati che solo la funzione restituita può toccare. Niente al di fuori di makeCounter può leggere o impostare count. Non c’è nessuna proprietà per questo e niente da ispezionare dalla console. Ottieni l’incapsulamento di un campo privato di una classe, costruito da una singola chiamata di funzione:
| |
Tutti e tre i metodi chiudono sulla stessa balance, quindi la condividono. Il codice esterno può raggiungerla solo attraverso i metodi che hai scelto di esporre. Questo è il module pattern: incapsulare stato privato e un’interfaccia pubblica in una singola chiamata di funzione, restituendo solo l’interfaccia.
Come si memoizza una funzione con una closure?
Chiudi su una Map e hai un wrapper di memoization, con una cache condivisa tra tutte le chiamate alla funzione restituita:
| |
Entrambi gli esempi seguono la stessa forma: chiami una funzione che prepara dello stato, e ottieni in cambio una o più funzioni che continuano a usarlo. La cache qui non elimina mai nulla, quindi non avvolgere così una funzione che riceve input distinti e illimitati. I decoratori Python si basano sullo stesso meccanismo: un decoratore chiude sulla funzione che avvolge e restituisce un sostituto che può ancora chiamarla.
Perché var in un loop rompe le closure?
Questo è l’errore con le closure che quasi tutti incontrano almeno una volta:
| |
Volevi 0 1 2. Hai ottenuto 3 3 3. var ha scope di funzione, non di blocco, quindi esiste esattamente una i per tutto il loop e tutte e tre le callback chiudono su quella. Quando una qualsiasi di esse viene eseguita, il loop è già finito e i è 3.
Dichiarala con let e il bug scompare:
| |
let ha scope di blocco, e un loop for con let nell’intestazione crea un binding separato per ogni iterazione. Tre i distinte. Ogni callback chiude su quella della propria iterazione.
Prima di let, la correzione era una IIFE che forzava un nuovo scope per iterazione passando i come parametro:
| |
Lo trovi ancora in codice più vecchio. Non scriverlo oggi: metti let nell’intestazione del loop.
Le closure possono causare memory leak?
Sì. Una closure mantiene il vero ambiente lessicale in cui vivono le sue variabili, per tutto il tempo in cui la closure stessa è raggiungibile. Collegane una a qualcosa di longevo, come un event listener del DOM, un array a livello di modulo, o una cache senza eviction, e tutto ciò che quell’ambiente porta con sé resta in memoria.
Il caso comune: un listener su un nodo del DOM chiude su un oggetto grande che l’handler usa a malapena. Il nodo viene rimosso dalla pagina, il listener non viene mai rimosso, ed entrambi, nodo e oggetto, restano vivi perché la closure continua a referenziarli. Chiama removeEventListener quando l’elemento scompare. E tieni le closure ristrette: se una callback ha bisogno di un solo campo, chiudi su quel campo, non sull’intero oggetto.
Il secondo costo è più silenzioso. Un metodo definito dentro un costruttore, invece che sul prototype, viene ricreato con la sua closure per ogni istanza, invece di essere condiviso tra tutte. Se la funzione interna non cattura nulla da quella esterna, non annidarla.
Quando usare una closure?
Probabilmente le hai già usate. Un debounce o throttle in JavaScript restituisce un wrapper che chiude su un timeoutId che persiste tra le chiamate, ed è così che annulla la chiamata precedente ancora in attesa. Vale lo stesso per qualsiasi callback che passi a setTimeout o a un gestore di promise e che legga una variabile dalla funzione che l’ha pianificata: l’event loop la esegue molto dopo che quella funzione è terminata.
Usa una closure di proposito quando hai bisogno di stato che persiste tra le chiamate ma non deve essere esposto come proprietà: un counter, una cache, un timer di debounce, una funzione configurata una volta e chiamata più avanti. Per una utility stateless che si limita a trasformare i suoi argomenti, salta il wrapper. Non c’è niente su cui chiudere, e l’annidamento aggiunge uno scope di cui devi tenere conto senza alcun vantaggio.