Non importa quale corso di programmazione tu abbia affrontato per addentrarti nel mondo dello sviluppo web, i mattoncini dai quali sarai senz'altro partito a prescindere dal percorso formatico e dal linguaggio di programmazione, con ogni probabilità, sono le variabili, le funzioni e i tipi.
Tralasciando le classi e i moduli, che sono principalmente modi per mettere in relazione funzioni e tipi, un altro grande pilastro di ogni corso di coding per principianti sono gli ambiti di valenza di questi mattoncini:
- dove è disponibile una variabile?
- dove è disponibile una funzione?
Come sempre, cerchiamo di restare ben ancorati alle domande, senza affrettarci a trovare risposte.
Riflettiamo, perciò, esattamente su cosa significa che una variabile è disponibile o meno in un dato punto del codice.
Disponibilità di una variabile
Prima di tutto, cosa significa che una variabile è disponibile?
Iniziamo a capire quando il linguaggio Javascript attiva una variabile, e quando la rende accessibile. Parliamo di hoisting.
Hoisting (sollevamento)
Iniziamo a sincronizzarci su una definizione provvisoria.
Che cos'è l'hoisting?
L'hoisting (sollevamento) è un meccanismo per cui una variabile dichiarata con "var" viene resa disponibile prima della sua dichiarazione. Questo è possibile perché il linguaggio Javascript legge tutte le dichiarazioni in anticipo e le mette a disposizione a monte dell'esecuzione.
console.log(a) // undefined
a = 2
console.log(a) // 2
var a = 1
console.log(a) // 1
Cosa è successo qui? Abbiamo potuto assegnare a = 2 prima di dichiarare var a, e il linguaggio Javascript ce l'ha lasciato fare perché "a" è soggetta a hoisting. Notare che solo la dichiarazione viene sollevata, e non l'inizializzazione al valore 1; infatti, nella prima riga a risulta undefined.
Che cos'altro è soggetto a hoisting?
sayHello() // Hello!
function sayHello() {
console.log('Hello!')
}
Le funzioni dichiarate con la parola chiave function sono soggette a hoisting. Questo è molto comodo perché ci consente di lasciare in fondo al nostro codice le implementazioni di dettaglio, separandole dal punto (più in alto nel codice) in cui descriviamo il comportamento.
Vediamo ora un caso limite. Si tratta di hoisting?
class SomeClass {
thisMethod() {
usesThisOtherMethod()
}
usesThisOtherMethod() {
console.log('this is declared after it is used')
}
}
All'atto pratico, sembrerebbe di sì: thisMethod fa uso di usesThisOtherMethod prima della sua dichiarazione. In realtà, qui non c'è alcun sollevamento, poiché la classe si limita a dichiarare i suoi metodi senza eseguirli. Nel momento in cui su un'istanza di SomeClass verrà chiamato il metodo thisMethod, il metodo usesThisOtherMethod sarà già stato dichiarato (quindi nessun hoisting).
Chi non è soggetto a hoisting?
- le variabili dichiarate con const o let
- le classi
Infatti, nessuna delle seguenti operazioni è legittima:
const a = new SomeClass() // Error: Cannot access 'SomeClass' before initialization
class SomeClass {
thisMethod() {
usesThisOtherMethod()
}
usesThisOtherMethod() {
console.log('this is declared after it is used')
}
}
someVariable = 22 // Error: Cannot access 'someVariable' before initialization
let someVariable
console.log(someConstant) // Error: Cannot access 'someConstant' before initialization
const someConstant = 'Hey!'
L'errore è sempre lo stesso: le nostre variabili, dichiarate in quel modo, non sono accessibili prima della loro dichiarazione.
La verità sull'hoisting
Precedentemente abbiamo scritto che la definizione proposta era provvisoria. Cosa intendevamo dire?
Intendevamo dire che, ad essere precisi, ti abbiamo mentito: tutte le variabili vengono sempre sollevate nel momento in cui una nuova funzione viene chiamata, oppure nel momento in cui viene eseguito lo script principale. La differenza sta nell'accessibilità o meno delle variabili, che in alcuni casi viene negata appositamente per impedire al programmatore informatico la possibilità di avvalersi dell'hoisting (che avviene comunque sotto il cofano), utilizzando codice prima di averlo menzionato.
Possiamo, dunque, immaginare cosa faccia il linguaggio Javascript ad ogni esecuzione di una funzione:
- Crea un nuovo contesto;
- Individua quali variabili (o funzioni, o classi) sono presenti in quel contesto;
- In base al tipo di dichiarazione, concede o meno l'utilizzo di una variabile (o funzione, o classe) prima della riga in cui viene dichiarata.
Abbiamo, quindi, capito che, di fatto, tutto ciò che viene dichiarato all'interno di un contesto, "nasce" prima che inizi l'esecuzione di quel contesto. Ci stiamo avvicinando a capire che cos'è la nascita di una variabile. Ma che cos'è un contesto, e come è correlato al ciclo di vita di una variabile?
Nascita e morte di una variabile
Abbiamo capito che una variabile nasce, insieme a tutto il resto, nel momento in cui il linguaggio Javascript attiva un contesto. Un contesto può essere avviato per due ragioni:
- una funzione viene chiamata
- uno scope viene aperto
// global scope
greet()
function greet() { // belongs to the global scope
// function scope
var a = 22 // belongs to greet
const b = x => 2 * x // belongs to greet
let c = 'some string' // belongs to greet
console.log({ a, b, c }) // {a: 22, b: ƒ, c: "some string"}
}
console.log({ a, b, c }) // Error: b is not defined
if (true) {
// block scope
var a = 22 // belongs to global scope
const b = x => 2 * x // belongs to the if scope
let c = 'some string' // belongs to the if scope
console.log({ a, b, c }) // {a: 22, b: ƒ, c: "some string"}
}
console.log({ a, b, c }) // Error: b is not defined
Ehi, abbiamo un sacco di roba qui.
In realtà, gli errori fermano l'esecuzione; se vuoi provare questo snippet, commenta le righe che falliscono per veder fallire le successive 😊
Per prima cosa, in questo snippet abbiamo tre ambiti (scope), cioè tre diversi tipi di contesto:
- un global scope: nasce nel momento in cui viene lanciato lo script;
- il function scope di greet: ne nasce uno nuovo ogni volta che greet viene chiamata;
- il block scope che nasce subito dopo if (true): viene generato una tantum nell'esecuzione dello script.
Facciamo un censimento dei nostri mattoncini:
Mattoncino |
Scope di appartenenza |
Inizio |
Fine |
greet |
global scope |
lancio dello script |
chiusura della tab |
a, b, c (in greet) |
function scope di greet |
chiamata di greet |
fine esecuzione di greet |
a (nell'if) |
global scope; il primo console.log non fallisce a causa dell'hoisting |
lancio dello script |
chiusura della tab |
b, c (nell'if) |
block scope dell'if |
ingresso nell'if |
uscita dall'if |
Abbiamo, quindi, creato la nostra mappatura tra l'appartenenza ad uno scope e il ciclo di vita di una variabile: semplicemente, il luogo e il modo in cui una variabile viene dichiarata ne determina l'appartenenza ad un preciso scope, e lo scope di appartenenza ne determina la nascita e la morte.
Nascita e morte di un valore
Quindi, è tutto qui? Decisamente no!
Andiamo a fondo e non parliamo più di variabili, ma di valori, cioè delle cose che "mettiamo dentro" alle variabili. Mentre pensiamo a questi valori, teniamo a mente che sono proprio loro ad occupare la memoria di un'applicazione, e che riconoscerne il ciclo di vita è fondamentale per ogni linguaggio di programmazione, per impiegare in modo ottimale le risorse della macchina.
Prendiamo il seguente script:
let a = 5
a = 6
console.log(a) // 6
let b = { property: 23 }
let c = b
c.property = 10
console.log(b, c) // {property: 10} {property: 10}
b = a
console.log(b, c) // 6 {property: 10}
c = 66
console.log(a, b, c) // 6 6 66
Facciamoci qualche domanda:
- Che fine fa il valore 5 quando a "diventa" 6?
- Perché la modifica fatta su c si ripercuote su b?
- Alla fine dello script, quali valori sono ancora "vivi"?
Stack e Heap
Alert! Quanto segue è una brutale semplificazione della realtà.
Ci scusiamo in anticipo per le inesattezze e le eccessive astrazioni; sono necessarie per non addentrarci nel dettaglio di come uno specifico linguaggio di programmazione gestisce queste cose, ma limitarci a fornire un'idea di come la gestione della memoria funzioni.
Partiamo da una distinzione:
- Lo stack (pila) è un tipo di memoria (più piccola ma più ordinata ed efficiente) in cui vanno a finire i "valori puri", che possono essere dati semplici (come
number
ebool
), oppure riferimenti a dati complessi (come oggetti, array e funzioni); la caratteristica dei valori puri è che non possono essere condivisi, ma devono essere copiati ogni volta che vengono messi dentro a una variabile. - Lo heap (cumulo) è un tipo di memoria (più capiente ma anche più caotica), in cui va a finire il contenuto dei "valori complessi" sopra citati. La caratteristica dei valori complessi è che sono semplicemente disponibili a chiunque ne possieda un riferimento (reference), cioè una cosa che più o meno in astratto ne individua la posizione all'interno dello heap.
Con questa distinzione in mente, andiamo a vedere al microscopio che succede nello stack e nello heap ad ogni assegnazione del nostro precedente spezzone di codice:
Assegnazione |
Stack |
Heap |
a = 5 |
aggiunto 5 |
|
a = 6 |
eliminato 5, aggiunto 6 |
|
b = { property: 23 } |
6 invariato, aggiunto riferimento b a heap |
aggiunto { property: 23 } |
c = b |
6 invariato, riferimento b invariato, aggiunto riferimento c a heap |
{ property: 23 } invariato |
c.property = 10 |
6 invariato, riferimenti b e c invariati |
{ property: 23 } mutato a { property: 10 } |
b = a |
6 invariato, eliminato riferimento b a heap, aggiunto 6 |
{ property: 10 } invariato |
c = 66 |
6 invariato, 6 invariato, eliminato riferimento c a heap, aggiunto 66 |
liberato { property: 10 } |
Alcune differenze saltano all'occhio nella gestione dei valori da parte delle due "diverse memorie":
- Nello stack i valori possono essere copiati o sostituiti, ma mai mutati. Anche i riferimenti allo stesso oggetto sullo heap sono sempre copie distinte, con destini indipendenti.
- Nello heap è sempre possibile dire quando un dato nasce, ma l'unico modo per stabilire se può morire è tenere traccia dei riferimenti che puntano ad esso; e qui subentra il concetto di reference count.
Garbage Collection e Reference Count
Ma perché lo heap funziona in modo così poco ordinato? Non basterebbe, semplicemente, uccidere un dato dello heap nel momento in cui viene liberato?
Gli obiettivi alla base di questa specifica organizzazione della memoria sono principalmente tre:
- Qualità del software;
- Performance del software;
- Produttività del programmatore.
Dobbiamo pensare che in alcuni contesti essere parsimoniosi con la memoria è piuttosto importante. La possibilità di condividere gli stessi dati fra più variabili, in più scope, in diverse funzioni del software, comporta effettivamente un notevole risparmio di risorse. Comporta, però, anche alcuni effetti indesiderati, come, ad esempio, il fatto che un componente potrebbe mutare lo stato della nostra applicazione mentre un altro componente sta lavorando sullo stesso stato.
Sta di fatto che, ormai un bel po' di tempo fa, fu deciso che la memoria heap andasse usata in modo meno automatico e più consapevole, proprio perché l'uso di questa memoria consentiva una maggior elasticità e una gestione più efficiente delle risorse. Questo comportava che lo spazio sullo heap andasse occupato e liberato manualmente.
Il bello dei linguaggi di programmazione ad alto livello come Javascript e molti altri linguaggi moderni, consiste proprio nell'aver sollevato noi sviluppatori web dall'onere di prenderci cura del nostro cumulo di memoria. Per assolvere a questo compito, i linguaggi ad alto livello creano uno strato di astrazione, chiamato runtime, in grado di ospitare l'esecuzione del programma e amministrare saggiamente lo heap al posto nostro. Un runtime può finire integrato nel compilato finale della nostra applicazione, oppure, nel caso di Javascript, del linguaggio Python e molti altri, si trova in un eseguibile dedicato, come ad esempio node o lo stesso browser, nel caso di Javascript. Uno dei componenti del runtime ha, dunque, il compito di deallocare gli spazi sullo heap che non hanno più nessun riferimento dallo stack o dallo heap (nulla ci vieta di mettere nello heap riferimenti ad altre aree dello heap!), e che, quindi, non saranno più consultati dal codice: questo componente è chiamato garbage collector, un nome che rende decisamente l'idea della mansione.
Riassumendo:
- il runtime ospita l'esecuzione del programma e si occupa di monitorare lo stato di stack e heap;
- in particolare, il runtime tiene il conto dei riferimenti (RefCount) che puntano ad un'area dello heap;
- quando questo conto va a zero, l'area dello heap è libera e può essere deallocata.
Poiché lo heap è destinato a strutture dati che possono anche essere molto voluminose, e poiché deallocare aree di memoria molto voluminose può essere a sua volta dispendioso, il runtime fa un'altra cosa per noi: individua autonomamente il momento giusto per deallocare la memoria, che non è necessariamente il momento in cui questa si libera. Questa attività di accumulare dati non più utili e deallocarli in blocco al momento opportuno si chiama garbage collection ed è la principale caratteristica dei linguaggi di programmazione a memoria gestita. Alcuni linguaggi (ad esempio C#) forniscono un'API per innescare la garbage collection manualmente. In JavaScript questo non è possibile, ma tra gli strumenti di sviluppo di Chrome, nella Tab dedicata alla Memoria, è disponibile un simpatico pulsantino che fa questa cosa.
Referenziazione vs Raggiungibilità
Abbiamo capito che nel linguaggio Javascript non dobbiamo preoccuparci di amministrare la memoria, perché qualcosa lo fa al posto nostro. Ma è sempre possibile stabilire che un oggetto è divenuto inutile?
Prendiamo il seguente caso:
function createCircularReferences() {
var a = {}
var b = {}
a.b = b // a references b
b.a = a // b references a
}
createCircularReferences()
La funzione createCircularReferences
ha tutto il potenziale per ingannare il nostro garbage collector, che troverà all'interno dello heap due oggetti che si referenziano a vicenda. Questo non è un problema da poco, perché dal momento che lo heap può referenziare aree di sé stesso, ogni volta che ci troviamo in presenza di un riferimento incrociato (non è così raro a pensarci bene) il nostro garbage collector non saprà cosa fare.
A meno che, cambiando approccio…
E se, anziché pensare ai riferimenti in generale, si stabilisse una regola di raggiungibilità di un dato a partire da un altro dato?
Abbiamo detto che in Javascript esiste un global scope. In questo global scope, vive un global object. Questo global object, si trova materialmente nello heap, ed è la radice di tutte le aree di memoria che restano vive per tutta la durata dell'applicazione. Vale a dire che ogni area utile della memoria dev'essere necessariamente raggiungibile a partire dal global object.
Se, dunque, nel corso di una funzione creiamo nello stack due riferimenti a due oggetti, che a loro volta presentano riferimenti incrociati, il runtime saprà che, non appena conclusa l'esecuzione di quella funzione, i due oggetti circolarmente collegati non sono più raggiungibili, cioè sono isolati, e quindi non potranno mai più essere referenziati di nuovo. Allora diventano cibo per il garbage collector.
Secondo MDN, dal 2012 tutti i browser hanno adottato questo criterio, chiamato mark-and-sweep, per operare la garbage collection.
Il ruolo del programmatore informatico
A questo punto, potresti pensare di non avere alcun potere nella gestione della memoria, e, dunque, che la cosa non dovrebbe preoccuparti; in un certo senso e fino a un certo punto, hai ragione.
Ad ogni modo, prima di lasciarti andare vogliamo segnalarti alcuni casi in cui dovrai comunque preoccuparti manualmente di isolare, e quindi dare in pasto al garbage collector, la memoria che non usi più.
Act locally, think globally
Mentre scrivi il tuo codice, pensa alle "tracce" che stai lasciando globalmente:
- Quando chiami addEventListener, stai passando una funzione che resterà per sempre agganciata ad un evento, quindi ricordati di chiamare removeEventListener quando hai finito!
- Quando conservi oggetti di utility oppure oggetti che contengono porzioni dello stato dell'applicazione, ricordati di troncare tutte le referenze a quegli oggetti, nel momento in cui non ti servono più. Il modo più semplice per farlo è assegnando null alle variabili che puntano agli oggetti che possono ormai essere… collezionati.
In questo modo aiuterai il tuo garbage collector ed eviterai i temutissimi memory leaks.