Vita e morte di una variabile - I fondamentali sulla gestione della memoria

Vita e morte di una variabile - I fondamentali sulla gestione della memoria

Marco Bellezza Di Marco Bellezza


javascript hoisting variabili

Non importa quale percorso didattico tu abbia affrontato per addentrarti nella programmazione, i mattoncini dai quali sarai senz'altro partito a prescindere dal corso e dal linguaggio 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 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

Anzitutto, cosa significa che una variabile è disponibile?
Iniziamo a capire quando 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é 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? Ho potuto assegnare a = 2 prima di dichiarare var a, e Javascript me 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

Prima ho scritto che la definizione proposta era provvisoria. In che senso?

Nel senso che, ad essere precisi, vi ho 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 la possibilità di avvalersi dell'hoisting (che avviene comunque sotto il cofano), utilizzando codice prima di averlo menzionato.

Possiamo dunque immaginare cosa faccia Javascript ad ogni esecuzione di una funzione:

  1. Crea un nuovo contesto;
  2. Individua quali variabili (o funzioni, o classi) sono presenti in quel contesto;
  3. 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" di fatto 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 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 😊

Anzitutto, 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

Ci siamo quindi fatti 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

Quanto segue è una brutale semplificazione della realtà.

Mi scuso in anticipo per le inesattezze e le eccessive astrazioni; sono necessarie per non addentrarmi nel dettaglio di come uno specifico linguaggio gestisce queste cose, ma limitarmi 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 e bool), oppure riferimenti a dati complessi (come oggettiarray 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 piuttosto che in altri, 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 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 ad alto livello come Javascript e molti altri linguaggi moderni, consiste proprio nell'aver sollevato noi programmatori 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, 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 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 in 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 scopevive 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

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 voglio 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.

Impara a programmare in 3 mesi con il Corso di Coding Hackademy su Laravel PHP

Diventa Sviluppatore web in 3 mesi

Scopri il coding bootcamp Hackademy

Programma Completo