javascript, informatica, programmazione

Jurassic Code

Siete davvero sicuri di sapere cosa succede quando utilizzate eventi, callback e promise in JavaScript?

Andrea Scianò Andrea Scianò 07 Gen 2019 · lettura da 20 min
Jurassic Code

Quale modo migliore di iniziare l'anno se non con un po' di esercizi in JavaScript? 🏋️

🍽️ Non vi faranno smaltire tutto quello che avete mangiato durante le feste ma potrebbero servire a riattivare il cervello 😅 per lo meno, questo è l'intento con cui scrivo questo articolo in vista del mio ritorno a lavoro e volevo condividerlo.

Ho pensato a qualche rompicapo che mi ricorda molto quelli che il mio professore di informatica (il grande prof. Matteo Baldoni) dava all'Università quando si svolgevano esercizi su Java 😁 se non altro, quelli trattavano argomenti differenti.

Indovina indovinello 🔮

Quali messaggi stampa il seguente codice? 🤓

new Promise((resolve, reject) => {
    setTimeout(reject, 5000);
})
.catch(e => console.log('Errore catturato'))
.then(() => console.log('Questo messaggio viene stampato?'))
.catch(e => console.log('E questo messaggio? Viene stampato?'));

... non sbirciare la soluzione, prova a risolverlo! 😆

...

...

Ragioniamoci su: la prima promise verrà rigettata dopo 5 secondi per cui il primo catch stamperà in console il messaggio Errore catturato.
Il primo then stamperà Questo messaggio viene stampato? perchè l'errore precedente è stato gestito ed una nuova promise è stata implicitamente restituita.
L'ultimo catch non stamperà nulla perchè non vi è più alcun errore da gestire, dunque riassumendo, l'output della nostra console sarà:

JavaScript event loop 🔁

JavaScript usa il suo event loop rimanendo perennemente in attesa di messaggi o eventi.
Quando viene ricevuto un messaggio, il motore lo processa, dopodichè torna in ascolto e attende quello successivo.
A livello di pseudo-codice sarebbe una cosa simile:

while (codaDiMessaggi.attendiMessaggio()) {
  codaDiMessaggi.processaProssimoMessaggio();
}

In JavaScript, tutto è un evento: il codice, le funzioni di callback che gestiscono i click sugli elementi, le interazioni dell'utente, la comunicazione con le API, i timeout ecc... Tutto arriva al motore di JavaScript come un evento.

È possibile spezzare le operazioni che saranno sicuramente più lente di quelle comuni in due eventi distinti:

  1. l'inizio di tale operazione
  2. la gestione del suo risultato una volta che quest'ultimo sarà disponibile.

Tipicamente queste operazioni sono quelle che richiedono interazioni con un attore di terze parti, per esempio la richiesta dati ad un'API.
Si inizia l'operazione e nel frattempo che il risultato viene elaborato e restituito alla funzione che ha iniziato l'operazione, JavaScript può gestire altri eventi.
Una volta ricevuta la risposta, si recuperano i dati e con essi si fa ciò che deve essere fatto.
Questo meccanismo prende il nome di codice asincrono:

  1. In un modello di programmazione sincrona, le cose accadono una alla volta. Quando si chiama una funzione che esegue un'azione prolungata, essa ritorna al chiamante solo quando l'azione è terminata e può restituire il risultato. Questo interrompe il programma per tutto il tempo richiesto dall'esecuzione di tale azione.

  2. In un modello di programmazione asincrona, più cose accadano in parallelo, nello stesso momento (per lo meno a livello concettuale, a basso livello le cose funzionano un po' diversamente). Quando si avvia un'azione, il programma continua a funzionare, al termine dell'azione, il programma viene informato e ottiene l'accesso al risultato.

JavaScript è single threaded, questo significa che un solo singolo thread gestisce il suo event loop. Per poter eseguire codice asincrono esso deve simulare il multi threading e viene in genere chiamato single-threaded multithreading: è veloce ed evita molti dei problemi legati al multi-threading reale, a patto che si cerchi di scrivere codice che segua le migliori best practice funzionali per evitare inaspettate conseguenze.

Definiamo un thread, che letteralmente significa filo, semplicemente come un flusso di istruzioni da eseguire.
Concettualmente in informatica si possono avere due tipi di processi:

  1. Single-thread (detti anche a thread singolo) proprio come quello che gestisce l'event loop di JavaScript. Se volete avere una rappresentazione grafica in mente, immaginateli come una singola linea che va dal punto di partenza dell'applicazione alla sua fine.

  2. Multi-thread, si pensi a loro come ad un albero: il processo parte da un punto e si può diramare in direzioni differenti.

Cosa significa scrivere codice funzionale 👩‍💻

Scrivere funzioni con codice funzionale (scusate il gioco di parole) significa calcolare il risultato che restituirà tale procedura, facendo affidamento solamente agli input inviati alla stessa e nulla più.
Qualunque riferimento a qualcosa al di fuori di essa, potrebbe cambiare nel tempo che intercorre tra la pianificazione dell'esecuzione di tale parte di codice e la sua esecuzione effettiva, proprio perchè come abbiamo visto nell'esempio precedente, in alcuni casi è possibile, per qualche motivo, ritardare l'esecuzione di alcune parti di codice mentre altre vengono prese in carico.
Quando una funzione cambia qualcosa all'esterno, tale operazione è in un certo senso "pericolosa" e dovrebbe essere fatta con cautela considerando eventualmente gli effetti collaterali.
Chiamare nuovamente tale funzione potrebbe avere conseguenze impredicibili, per cui: una funzione pura, scritta con codice funzionale, restituisce sempre lo stesso output partendo dagli stessi input. Non importa quando, come o perché la si richiami.

Non ci credete? Cosa stampa il seguente codice? 🤓

let pippo = 1;
let ripeti = true;

setTimeout(() => {
    pippo = 0;
}, 5000);

setTimeout(function stampaPippo() {
    console.log('Valore di pippo: ', pippo);

    if (ripeti) {
        setTimeout(stampaPippo, 1500);
    }

    ripeti = false;
}, 4000);

... di nuovo che provi a sbirciare la soluzione? 😜

...

...

L'output della console sarà:

Partendo dal principio, la prima funzione setTimeout inizializzerà il valore di pippo a 0 non prima che 5 secondi siano trascorsi.

Nel contempo però il restante codice viene eseguito, per cui si entrerà nella seconda setTimeout, quella contenente la funziona denominata stampaPippo.

Dopo 4 secondi la funzione stampaPippo viene eseguita: ricordiamo che la prima setTimeout deve ancora essere processata e, essendo passati 4 secondi, essa verrà eseguita tra un secondo.

L'esecuzione di stampaPippo porterà alla stampa della stringa Valore di pippo: 1 in quanto ancora nessuno ha cambiato il valore iniziale della variabile pippo.

Proseguendo, la variabile ripeti è true e la terza funzione setTimeout all'interno dello statement if indica che la funzione stampaPippo deve essere ri-eseguita tra un secondo e mezzo. Anche qui, JavaScript proseguirà nella sua esecuzione "tenendo a mente" che dovrà lanciare tale procedura ed imposterà la variabile ripeti a false.

Al termine dell'esecuzione di questa funzione, trascorso il secondo rimanente, JavaScript eseguirà la funzione all'interno della prima setTimeout inizializzando il valore della variabile pippo a 0.

In questo istante, 5 secondi sono passati, stampaPippo è stata eseguita interamente una volta al pari della funzione all'interno della prima setTimeout: tra mezzo secondo verrà ri-eseguita la funzione stampaPippo richiamata dalla setTimeout all'interno dell' if.

Rieseguendo nuovamente stampaPippo verrà prodotta la stringa Valore di pippo: 0 in quanto il valore di pippo è stato cambiato un attimo prima dalla funzione passata alla prima setTimeout.

Nient'altro verrà stampato perchè proseguendo nella sua esecuzione, la variabile ripeti, essendo stata valorizzata a false dall'esecuzione precedente di stampaPippo, non permetterà di entrare nuovamente nell' if, per cui nessun altra funzione potrà produrre alcun output.

Difficile? Questo è solo un piccolo esempio di quello che può succedere cambiando il mondo esterno 😏 .

Ti richiamo io ☎️

Tutte le setTimeout che abbiamo incontrato usano un meccanismo di callback.
Di cosa si tratta? Il termine deriva ovviamente dall'inglese e significa letteralmente richiamare (ma con un'accezione diversa, io direi che ti richiamo io più tardi rende meglio l'idea).

Per mezzo di esse, JavaScript riesce a gestire situazioni asincrone come quelle che abbiamo visto: invece di restituire immediatamente il risultato, come la maggior parte delle funzioni, quelle che utilizzano le callback richiedono del tempo per produrre un output.
Le parole asincrono o async significano semplicemente che la funzione "richiede un po' di tempo" e "viene eseguita in futuro, non subito".

Tecnicamente si passa una funzione come parametro ad un'altra funzione (esempio: stampaPippo passata a setTimeout) e si richiama quando necessario (esempio: il timer della setTimeout ha esaurito il conto alla rovescia).

Tutto molto bello sinchè siamo in piccolo ma quando le cose iniziano a complicarsi e si usano callback a ripetizione, ci si imbatte in quello che è stato definito callback hell (inferno di callback).

La prima causa di callback hell è il tentativo, da parte dei programmatori, di scrivere codice JavaScript come se l'esecuzione avvenisse nello stesso modo in cui viene letto: dall'alto verso il basso.
Molte persone fanno questo errore: in altri linguaggi come il C, Ruby o Python, solitamente un'operazione svolta alla linea 1 finisce prima che il codice alla linea 2 venga eseguito, JavaScript è diverso.

setTimeout(() => {
    console.log('1. Prima funzione eseguita');
    setTimeout(() => {
        console.log('2. Seconda funzione eseguita');
        setTimeout(() => {
            console.log('3. Terza funzione eseguita');
            setTimeout(() => {
                console.log('4. Quarta funzione eseguita');
            }, 2000);
        }, 2000);
    }, 2000);
}, 2000);

Il callback hell si manifesta anche graficamente, come se ci fosse un grande triangolo fatto di spazi bianchi lungo il margine sinistro. Solitamente lo si vede con funzioni che richiamano API con callback per la gestione del successo o del fallimento della chiamata.

Senza esagerare, si può facilmente arrivare ad avere qualcosa di simile (Ryu compare solo se avete bevuto qualche bicchiere in più):

Prendiamo il codice che stiamo utilizzando e invece di scrivere n-volte setTimeout possiamo creare una funzione che richiami semplicemente la callback passata:

function aspetta(funzioneCallback) {
    setTimeout(funzioneCallback, 2000);
};

aspetta(() => {
    console.log('1. Prima funzione eseguita');
    aspetta(() => {
        console.log('2. Seconda funzione eseguita');
        aspetta(() => {
            console.log('3. Terza funzione eseguita');
            aspetta(() => {
                console.log('4. Quarta funzione eseguita');
            });
        });
    });
});

Ora dovrebbe essere un po' più leggibile.

Te lo prometto 🤞

Un bel giorno vennero inventate le promise: una promise (promessa in italiano) è un oggetto che accetta due callback: resolve e reject (letteralmente risolvi e respingi) la prima per la gestione dei risultati, la seconda per la gestione degli errori.

Tale stratagemma rende il codice più pulito: due diverse funzioni per gestire due diversi flussi.
Con le callback wrappate in oggetti, esse sono più facili da gestire, più facile assicurarsi che una promise restituisca sempre una promise, più facile controllare che tutto all'interno di una promise rimanga sempre all'interno di una promise; si possono concatenare, si possono gestire gli errori e si possono eseguire cose in parallelo:

function aspettaUnPo() {
    return new Promise(resolve => setTimeout(resolve, 2000))
};

aspettaUnPo()
    .then(() => console.log('1. Prima funzione eseguita'))
    .then(aspettaUnPo)
    .then(() => console.log('2. Seconda funzione eseguita'))
    .then(aspettaUnPo)
    .then(() => console.log('3. Terza funzione eseguita'))
    .then(aspettaUnPo)
    .then(() => console.log('4. Quarta funzione eseguita'))

Il codice risulta più chiaro dell'esempio precedente perchè si eliminano le funzioni innestate l'una dentro l'altra. Un'altra evoluzione ci porta a scrivere qualcosa come:

function aspettaUnPo() {
    return new Promise(resolve => setTimeout(resolve, 2000))
};

aspettaUnPo()
    .then(() => {
        console.log('1. Prima funzione eseguita');
        return aspettaUnPo();
    })
    .then(() => {
        console.log('2. Seconda funzione eseguita');
        return aspettaUnPo();
    })
    .then(() => {
        console.log('3. Terza funzione eseguita');
        return aspettaUnPo();
    })
    .then(() => console.log('4. Quarta funzione eseguita'));

Qualsiasi cosa si restituisca da una promise è wrappata in un'altra promise. Se si restituisce una promise esplicitamente, essa "si appiattisce" con le altre e non è necessario un ulteriore then per leggere il risultato, come nel seguente esempio:

function aspettaUnPo() {
    return new Promise(resolve => setTimeout(resolve, 2000))
};

function aspettaDiPiu() {
    return aspettaUnPo()
        .then(() => {
            console.log('1. Prima funzione eseguita');
            return aspettaUnPo();
        })
        .then(() => {
            console.log('2. Seconda funzione eseguita');
            return aspettaUnPo();
        })
        .then(() => {
            console.log('3. Terza funzione eseguita');
            return aspettaUnPo();
        })
        .then(() => {
            console.log('4. Quarta funzione eseguita');
            return 4;
        });
}

// Stampa l'oggetto Promise
console.log(aspettaDiPiu());

// Stampa la stringa "Funzioni eseguite in 4 passi"
aspettaDiPiu().then(numeroPassi => console.log(`Funzioni eseguite in ${numeroPassi} passi`));

In questo caso la funzione aspettaDiPiu restituisce aspettaUnPo che è una promise, ma anche quest'ultima restituisce un'altra promise.
Nonostante vi sia una promise dentro un'altra promise, non necessitiamo di utilizzare qualcosa del tipo promise.then.then per leggere il risultato restituito dalla promise più interna, bensì esse vengono appiattite sullo stesso livello in modo che siano leggibili solamente con un solo then.
Se ci pensate, questo nuovo oggetto è stato creato proprio per evitare questa situazione, altrimenti avremmo nuovamente il problema mostrato in precedenza con l'inferno di callback.

Altre osservazioni: con la riga console.log(aspettaDiPiu()) otteniamo direttamente la stampa dell'oggetto Promise e non del risultato che essa restituisce.
Una cosa molto importante da non dimenticare è che una volta richiamata una promise, come in questo caso, anche se non utilizziamo il suo risultato essa verrà comunque eseguita.
Ad una promise non interessa se la si usi oppure no, viene "eseguita ebbasta", notare l'output della console:


L'output dell'oggetto Promise è prodotto dall'esecuzione della riga di codice console.log(aspettaDiPiu()).
Alcune delle stringhe sono ripetute perchè con l'esecuzione di essa, anche se non andremo a leggere il risultato restituito, la promise verrà comunque eseguita producendo tali stampe.
Le altre stringhe sono date in output dalla promise eseguita nell'ultima riga di codice dell'esempio, nella quale viene anche letto ed utilizzato il risultato restituito una volta disponibile (il numero 4).

L'ultima promise della catena nell'esempio ritorna il valore 4, il quale è stato possibile leggere con un solo then.
Regole da tenere bene a mente:

  1. promise.then restituisce sempre l'ultimo valore tornato da una catena di promesse.

  2. promise.catch cattura sempre l'ultimo errore di una catena di promesse e restituisce implicitamente una promessa risolta.

Anzichè avere sempre una catena di promesse, possiamo rendere più veloce la nostra attesa utilizzando Promise.all: ci consente di eseguire promesse in parallelo e gestire i risultati in una sola volta:

function aspettaUnPo() {
    return new Promise(resolve => setTimeout(resolve, 2000))
};

function aspettaMeno() {
    Promise.all([
        aspettaUnPo(),
        aspettaUnPo(),
        aspettaUnPo(),
        aspettaUnPo(),
    ]).then((risultati) => {
        // Utilizza i differenti risultati in qualche modo
        console.log('4 promise eseguite in parallelo');
    });
}

aspettaMeno();

Molto meglio no? Non vi è nemmeno necessità di averle in una catena.

La nostra funzione aspettaUnPo non ritorna alcun valore, ma se lo facesse, la variabile risultati passata come argomento alla then all'interno della funzione aspettaMeno, conterrebbe un array con i rispettivi risultati delle varie promise:

function ciao() {
    Promise.all([
        new Promise(resolve => setTimeout(() => {
            resolve('c');
        }, 2000)),
        new Promise(resolve => setTimeout(() => {
            resolve('i');
        }, 2000)),
        new Promise(resolve => setTimeout(() => {
            resolve('a');
        }, 2000)),
        new Promise(resolve => setTimeout(() => {
            resolve('o');
        }, 2000)),
    ]).then((ris) => {
        // Stampa la stringa "ciao"
        console.log(`${ris[0]}${ris[1]}${ris[2]}${ris[3]}`);
    });
}

ciao();

Non appena tutte le promesse sono risolte, il codice di quest'ultimo esempio stamperà semplicemente la stringa ciao dato che ogni promise restituisce una singola lettera componente la parola.

Aspetta un momento... ✋

Le promise sono grandiose e tutto, ma come abbiamo notato, alla fin fine utilizzano sempre callback.
In ECMAScript 2017 (ricordate come distinguere le varie versioni di JavaScript vero?) è stato introdotto un po' di syntax sugar che sostituisce il concatenamento di promesse. Il nostro codice diventerebbe così:

function aspettaUnPo() {
    return new Promise(resolve => setTimeout(resolve, 2000))
};

async function figoAspettareCosi() {
    await aspettaUnPo();
    console.log('1. Prima funzione eseguita');

    await aspettaUnPo();
    console.log('2. Seconda funzione eseguita');

    await aspettaUnPo();
    console.log('3. Terza funzione eseguita');

    await aspettaUnPo();
    console.log('4. Quarta funzione eseguita');
}

figoAspettareCosi();

Ora si può avere una scrittura estremamente lineare, da leggere come normale codice imperativo.
Le funzioni che utilizzano await devono essere contrassegnate come funzioni asincrone utilizzando la parola chiave async prima della loro definizione, in questo modo JavaScript sa che deve wrapparle in una promise.
Ogni await si traduce in un .then() che racchiude il resto del codice dietro di esso.
Si possono ottenere risultati da una funzione asincrona usando await proprio nello stesso modo in cui faresti con una catena di promise, è possibile aspettare ogni funzione async con await:

function aspettaUnPo() {
    return new Promise(resolve => setTimeout(resolve, 2000))
};

async function contaPassi() {
    await aspettaUnPo();
    console.log('1. Prima funzione eseguita');

    await aspettaUnPo();
    console.log('2. Seconda funzione eseguita');

    await aspettaUnPo();
    console.log('3. Terza funzione eseguita');

    await aspettaUnPo();
    console.log('4. Quarta funzione eseguita');
    return 4;
}

(async function() {
    console.log(`Funzioni eseguite in ${await contaPassi()} passi`)
})();

Importante: non puoi aspettare con await qualcosa al di fuori di una funzione asincrona definita con async, ecco perchè ho dovuto dichiarare l'ultima funzione di questo esempio come async.
Una volta che sei in terra asincrona, devi rimanere in terra asincrona.

Altro esempio interessante:

function aspettaUnPo() {
    return new Promise(resolve => setTimeout(resolve, 2000))
};

async function aspettaConCiclo() {
    for (const aspetta in [1, 2, 3, 4]) {
        await aspettaUnPo();
        console.log('Aspetto...');
    }
    console.log('Aspettato 4 volte in sequenza');
}

aspettaConCiclo();

Output della console:

È possibile utilizzare await per attendere i valori all'interno di un ciclo. L'iterazione successiva avviene solo dopo che la promise è stata risolta.
Se si vuole aspettare nuovamente in parallelo, basta continuare ad usare Promise.all:

function aspettaUnPo() {
    return new Promise(resolve => setTimeout(resolve, 2000))
};

async function aspettaInParallelo() {
    await Promise.all([
        aspettaUnPo(),
        aspettaUnPo(), 
        aspettaUnPo(),
        aspettaUnPo(),
    ]);
    return 4;
};

(async function() {
    // Stampa la stringa "4 funzioni eseguite in parallelo"
    console.log(`${await aspettaInParallelo()} funzioni eseguite in parallelo`);
})();

Promise.all restituisce una promessa e con await si può aspettare qualsiasi promise.

Rivelazione: il vero multi threading esiste anche in JavaScript 😱

Tutto questo codice asincrono significa computazione intensiva: l'event loop è a singolo thread per cui esso è ottimo per affrontare problemi che richiedono molta attesa, ma quando l'elaborazione di un singolo evento inizia a richiedere troppo tempo, altri eventi ne soffrono.

È possibile accorgersene quando si mostra qualcosa di pesante a livello di data visualization oppure con un ciclo di oltre 50.000 elementi in un array: i campi di input iniziano a rallentare e tutta la nostra UI comincia a "laggare".

Una soluzione potrebbe essere quella di utilizzare i web worker: reali processi JavaScript separati che è possibile mettere in comunicazione con il codice che li ha creati attraverso lo scambio di messaggi.

È un argomento molto interessante ma anche veramente ampio, devo giocarci un po' prima di poter raccontare qualcosa su questo 😅.
Scambio di messaggi nei web worker implica la possibilità di poter wrappare questi processi separati all'interno di promise diverse, utilizzando poi await si potrebbe attendere che il codice all'interno di questi thread completamente distinti sia effettivamente eseguito ed il risultato restituito.

Conclusioni ✔️

  1. Le callback sono una fantastica idea, le usi anche se non lo sai.
  2. Le promise rendono le callback più facili da concatenare, un po' più facili da leggere e molto meglio per la manutenzione del codice.
  3. Il syntax sugar introdotto con async/await rende il codice lineare, ancora più facile da leggere e sai sempre quando qualcosa è in attesa di un risultato proveniente da un'altra riga di codice.

E voi, avete altri esempi di Jurassic Code?