Closures in JavaScript
- Le closure in JavaScript sono funzioni che ricordano l'ambiente lessicale in cui sono state definite, consentendo loro di accedere alle variabili di quel contesto anche quando vengono invocate al di fuori di esso.
- Le closure possono essere utilizzate per creare funzioni con stato privato, mantenendo le variabili locali inaccessibili dall'esterno.
- Le closure sono utili per implementare modelli di programmazione come i moduli, dove si desidera nascondere l'implementazione interna e fornire un'interfaccia pubblica.
- Le closure possono essere utilizzate per creare funzioni che generano altre funzioni, consentendo la creazione di funzioni personalizzate con parametri predefiniti.
- Le closure possono essere utilizzate per implementare contatori, accumulatori e altre strutture di dati che richiedono uno stato persistente tra le invocazioni.
- Le closure possono essere create utilizzando funzioni annidate, che catturano le variabili del loro contesto esterno.
Closures
Come la maggior parte dei linguaggi di programmazione moderni, JavaScript utilizza il lexical scoping. Questo significa che le funzioni vengono eseguite utilizzando l'ambito delle variabili che era in vigore quando sono state definite, non l'ambito delle variabili che è in vigore quando vengono invocate. Per implementare il lexical scoping, lo stato interno di un oggetto funzione JavaScript deve includere non solo il codice della funzione ma anche un riferimento all'ambito in cui appare la definizione della funzione. Questa combinazione di un oggetto funzione e un ambito (un insieme di associazioni di variabili) in cui le variabili della funzione vengono risolte è chiamata closure (o chiusura lessicale) nella letteratura informatica.
Tecnicamente, tutte le funzioni JavaScript sono closure, ma poiché la maggior parte delle funzioni vengono invocate dallo stesso ambito in cui sono state definite, normalmente non importa davvero che ci sia una closure coinvolta. Le closure diventano interessanti quando vengono invocate da un ambito diverso da quello in cui sono state definite. Questo accade più comunemente quando un oggetto funzione annidato viene restituito dalla funzione all'interno della quale è stato definito. Ci sono diverse tecniche di programmazione potenti che coinvolgono questo tipo di closure di funzioni annidate, e il loro uso è diventato relativamente comune nella programmazione JavaScript. Le closure possono sembrare confuse quando si incontrano per la prima volta, ma è importante che si comprendano abbastanza bene da usarle comodamente.
Il primo passo per comprendere le closure è rivedere le regole del lexical scoping per le funzioni annidate. Si consideri il seguente codice:
// Una variabile globale
let ambito = "ambito globale";
function verificaAmbito() {
// Una variabile locale
let ambito = "ambito locale";
// Restituisce il valore in ambito qui
function f() {
return ambito;
}
return f();
}
// => "ambito locale"
verificaAmbito();
La funzione verificaAmbito()
dichiara una variabile locale e poi definisce e invoca una funzione che restituisce il valore di quella variabile. Dovrebbe essere chiaro perché la chiamata a verificaAmbito()
restituisce "ambito locale". Ora, si cambi il codice leggermente. Si riesce a dire cosa restituirà questo codice?
// Una variabile globale
let ambito = "ambito globale";
function verificaAmbito() {
// Una variabile locale
let ambito = "ambito locale";
// Restituisce il valore in ambito qui
function f() {
return ambito;
}
return f;
}
// Cosa restituisce questo?
let s = verificaAmbito()();
In questo codice, una coppia di parentesi si è spostata dall'interno di verificaAmbito()
all'esterno di essa. Invece di invocare la funzione annidata e restituire il suo risultato, verificaAmbito()
ora restituisce semplicemente l'oggetto funzione annidato stesso. Cosa succede quando invochiamo quella funzione annidata (con la seconda coppia di parentesi nell'ultima riga di codice) al di fuori della funzione in cui è stata definita?
Si ricordi la regola fondamentale del lexical scoping: le funzioni JavaScript vengono eseguite utilizzando l'ambito in cui sono state definite. La funzione annidata f()
è stata definita in un ambito dove la variabile ambito
era associata al valore "ambito locale". Quella associazione è ancora in vigore quando f
viene eseguita, indipendentemente da dove viene eseguita. Quindi l'ultima riga dell'esempio di codice precedente restituisce "ambito locale", non "ambito globale". Questa, in sintesi, è la natura sorprendente e potente delle closure: catturano le associazioni delle variabili locali (e dei parametri) della funzione esterna all'interno della quale sono definite.
Nelle lezioni precedenti, abbiamo definito una funzione interoUnico()
che utilizzava una proprietà della funzione stessa per tenere traccia del prossimo valore da restituire. Una limitazione di quell'approccio è che codice difettoso o malizioso potrebbe resettare il contatore o impostarlo su un non-intero, causando alla funzione interoUnico()
di violare la parte "unica" o "intero" del suo contratto. Le closure catturano le variabili locali di una singola invocazione di funzione e possono utilizzare quelle variabili come stato privato. Ecco come potremmo riscrivere interoUnico()
utilizzando un'espressione di funzione immediatamente invocata per definire un namespace e una closure che utilizza quel namespace per mantenere privato il suo stato:
let interoUnico = (function() { // Definisce e invoca
let contatore = 0; // Stato privato della funzione sotto
return function() { return contatore++; };
}());
interoUnico() // => 0
interoUnico() // => 1
Per comprendere questo codice, dobbiamo leggerlo attentamente. A prima vista, la prima riga di codice sembra che stia assegnando una funzione alla variabile interoUnico
. In realtà, il codice sta definendo e invocando (come suggerito dalla parentesi aperta sulla prima riga) una funzione, quindi è il valore di ritorno della funzione che viene assegnato a interoUnico
. Ora, se studiamo il corpo della funzione, vediamo che il suo valore di ritorno è un'altra funzione. È questo oggetto funzione annidato che viene assegnato a interoUnico
. La funzione annidata ha accesso alle variabili nel suo ambito e può utilizzare la variabile contatore
definita nella funzione esterna. Una volta che quella funzione esterna restituisce, nessun altro codice può vedere la variabile contatore
: la funzione interna ha accesso esclusivo ad essa.
Le variabili private come contatore
non devono essere esclusive di una singola closure: è perfettamente possibile che due o più funzioni annidate siano definite all'interno della stessa funzione esterna e condividano lo stesso ambito. Consideriamo il seguente codice:
function contatore() {
let n = 0;
return {
// Restituisce il prossimo intero
conta: function() {
return n++;
},
// Resetta lo stato interno
reset: function() {
n = 0;
}
};
}
// Crea due contatori
let c = contatore();
let d = contatore();
// => 0
c.conta();
// => 0: contano indipendentemente
d.conta();
// I metodi reset() e conta() condividono lo stato
c.reset();
// => 0: perché si è resettato c
c.conta();
// => 1: d non è stato resettato
d.conta();
La funzione contatore()
restituisce un oggetto "contatore". Questo oggetto ha due metodi: conta()
restituisce il prossimo intero, e reset()
resetta lo stato interno. La prima cosa da comprendere è che i due metodi condividono l'accesso alla variabile privata n
. La seconda cosa da comprendere è che ogni invocazione di contatore()
crea un nuovo ambito, indipendente dagli ambiti utilizzati dalle invocazioni precedenti, e una nuova variabile privata all'interno di quell'ambito. Quindi se chiamiamo contatore()
due volte, otteniamo due oggetti contatore con diverse variabili private. Chiamare conta()
o reset()
su un oggetto contatore non ha effetto sull'altro.
Vale la pena notare qui che possiamo combinare questa tecnica delle closure con i getter e setter delle proprietà. La seguente versione della funzione contatore()
è una variazione del codice che abbiamo studiato nelle lezioni sugli oggetti, ma utilizza le closure per lo stato privato piuttosto che fare affidamento su una proprietà di oggetto regolare:
function contatore(n) { // L'argomento della funzione n è la variabile privata
return {
// Il metodo getter della proprietà restituisce e incrementa la variabile contatore privata.
get conta() { return n++; },
// Il setter della proprietà non permette che il valore di n diminuisca
set conta(m) {
if (m > n) n = m;
else throw Error("conta può essere impostato solo su un valore maggiore");
}
};
}
let c = contatore(1000);
c.conta // => 1000
c.conta // => 1001
c.conta = 2000;
c.conta // => 2000
c.conta = 2000; // !Error: conta può essere impostato solo su un valore maggiore
Notiamo che questa versione della funzione contatore()
non dichiara una variabile locale ma utilizza semplicemente il suo parametro n
per contenere lo stato privato condiviso dai metodi di accesso alle proprietà. Questo permette al chiamante di contatore()
di specificare il valore iniziale della variabile privata.
L'Esempio che segue è una generalizzazione della tecnica dello stato privato condiviso attraverso le closure che abbiamo dimostrato qui. Questo esempio definisce una funzione aggiungiProprietaPrivata()
che definisce una variabile privata e due funzioni annidate per ottenere e impostare il valore di quella variabile. Aggiunge queste funzioni annidate come metodi dell'oggetto che specificate.
// Questa funzione aggiunge metodi di accesso a proprietà per una proprietà con
// il nome specificato all'oggetto o. I metodi sono chiamati get<name>
// e set<name>. Se viene fornita una funzione predicato, il metodo
// setter la usa per testare la validità del suo argomento prima di memorizzarlo.
// Se il predicato restituisce false, il metodo setter lancia un'eccezione.
//
// La cosa insolita di questa funzione è che il valore della proprietà
// che viene manipolato dai metodi getter e setter non è memorizzato nell'
// oggetto o. Invece, il valore è memorizzato solo in una variabile locale
// di questa funzione. I metodi getter e setter sono anche definiti
// localmente a questa funzione e quindi hanno accesso a questa variabile locale.
// Questo significa che il valore è privato ai due metodi di accesso, e non
// può essere impostato o modificato eccetto attraverso il metodo setter.
function aggiungiProprietaPrivata(o, nome, predicato) {
let valore; // Questo è il valore della proprietà
// Il metodo getter semplicemente restituisce il valore.
o[`get${nome}`] = function() { return valore; };
// Il metodo setter memorizza il valore o lancia un'eccezione se
// il predicato rifiuta il valore.
o[`set${nome}`] = function(v) {
if (predicato && !predicato(v)) {
throw new TypeError(`set${nome}: valore non valido ${v}`);
} else {
valore = v;
}
};
}
// Il seguente codice dimostra il metodo aggiungiProprietaPrivata().
let o = {}; // Ecco un oggetto vuoto
// Aggiungi metodi di accesso alla proprietà getNome e setNome()
// Assicurati che solo valori stringa siano permessi
aggiungiProprietaPrivata(o, "Nome", x => typeof x === "string");
o.setNome("Franco"); // Imposta il valore della proprietà
o.getNome() // => "Franco"
o.setNome(0); // !TypeError: tentativo di impostare un valore del tipo sbagliato
Abbiamo ora visto diversi esempi in cui due closures sono definite nello stesso ambito e condividono l'accesso alla stessa variabile o variabili private. Questa è una tecnica importante, ma è altrettanto importante riconoscere quando le closures condividono inavvertitamente l'accesso a una variabile che non dovrebbero condividere. Consideriamo il seguente codice:
// Questa funzione restituisce una funzione che restituisce sempre v
function funcCostante(v) { return () => v; }
// Crea un array di funzioni costanti:
let funzioni = [];
for(var i = 0; i < 10; i++) funzioni[i] = funcCostante(i);
// La funzione all'elemento dell'array 5 restituisce il valore 5.
funzioni[5]() // => 5
Quando si lavora con codice come questo che crea multiple closures usando un loop, è un errore comune cercare di spostare il loop all'interno della funzione che definisce le closures. Pensiamo al seguente codice, per esempio:
// Restituisce un array di funzioni che restituiscono i valori 0-9
function funzioniCostanti() {
let funzioni = [];
for(var i = 0; i < 10; i++) {
funzioni[i] = () => i;
}
return funzioni;
}
let funzioni = funzioniCostanti();
funzioni[5]() // => 10; Perché non restituisce 5?
Questo codice crea 10 closures e le memorizza in un array. Le closures sono tutte definite all'interno della stessa invocazione della funzione, quindi condividono l'accesso alla variabile i
. Quando funzioniCostanti()
restituisce, il valore della variabile i
è 10, e tutte le 10 closures condividono questo valore. Pertanto, tutte le funzioni nell'array restituito di funzioni restituiscono lo stesso valore, che non è affatto quello che volevamo. È importante ricordare che l'ambito associato a una closure è "vivo". Le funzioni annidate non fanno copie private dell'ambito o creano snapshot statici dei binding delle variabili. Fondamentalmente, il problema qui è che le variabili dichiarate con var
sono definite in tutta la funzione. Il nostro loop for
dichiara la variabile del loop con var i
, quindi la variabile i
è definita in tutta la funzione piuttosto che essere limitata più strettamente al corpo del loop. Il codice dimostra una categoria comune di bug in ES5 e precedenti, ma l'introduzione delle variabili con ambito di blocco in ES6 affronta il problema. Se sostituiamo semplicemente var
con let
o const
, allora il problema scompare. Poiché let
e const
hanno ambito di blocco, ogni iterazione del loop definisce un ambito che è indipendente dagli ambiti per tutte le altre iterazioni, e ognuno di questi ambiti ha il proprio binding indipendente di i
.
Un'altra cosa da ricordare quando si scrivono closures è che this
è una parola chiave JavaScript, non una variabile. Come discusso in precedenza, le arrow functions ereditano il valore this
della funzione che le contiene, ma le funzioni definite con la parola chiave function
non lo fanno. Quindi se stai scrivendo una closure che ha bisogno di usare il valore this
della sua funzione contenitore, dovresti usare una arrow function, o chiamare bind()
sulla closure prima di restituirla, o assegnare il valore this
esterno a una variabile che la tua closure erediterà:
const self = this; // Rendi il valore this disponibile alle funzioni annidate