Vai al contenuto

A cosa servono gli iteratori asincroni in JavaScript

Gli iteratori asincroni in JavaScript, rappresentati dalla sintassi for-await-of, servono per lavorare con sequenze di dati che arrivano in modo asincrono, come le Promise. Questo è particolarmente utile quando dobbiamo gestire una serie di operazioni che non sono immediate e potrebbero richiedere tempo, come il recupero di dati da un’API o la lettura di un file in modo asincrono.

Consideriamo una situazione abbastanza comune: stiamo lavorando con Node.js e dobbiamo leggere un file, riga per riga. Node ha un modulo incorporato chiamato  readline. Questa API è un wrapper che ti consente di leggere i dati dal flusso di input riga per riga, invece di analizzare il buffer di input e suddividere il testo in piccoli frammenti. È particolarmente utile per applicazioni che richiedono l’interazione con l’utente tramite la riga di comando, come strumenti di CLI (Command Line Interface) o script interattivi.

const fs = require('fs')
const readline = require('readline')
const reader = readline.createInterface({
  input: fs.createReadStream('./file.txt'),
  crlfDelay: Infinity // Gestisce i ritorni a capo in modo corretto
})
// Ascolta l'evento 'line' per ogni riga letta
reader.on('line', (line) => {
    console.log(`Linea: ${line}`);
});

// Ascolta l'evento 'close' quando il file è completamente letto
reader.on('close', () => {
    console.log('Lettura del file completata.');
});

// Gestisce eventuali errori durante la lettura del file
reader.on('error', (error) => {
    console.error(`Errore durante la lettura del file: ${error.message}`);
});

Immaginiamo adesso di avere un semplice file:

linea 1
linea 2
linea 3

Se eseguiamo questo codice sul file che abbiamo creato, otterremo un output riga per riga sulla console. Tuttavia, lavorare con gli eventi non è il modo migliore per creare codice gestibile. Il fatto è che gli eventi sono completamente asincroni e possono interrompere il flusso del codice, poiché vengono attivati ​​​​fuori ordine e l’azione può essere assegnata solo tramite un ascoltatore.

L’API readline fornisce anche un iteratore asincrono. Ciò significa che invece di leggere la stringa tramite “l’ascolto” dell’evento line, leggeremo la stringa utilizzando un nuovo modo utilizzando il ciclo for.

Ricordiamo che il ciclo for, può essere utilizzato in diversi modi. Il più comune è quello di usare un contatore e una condizione:

for (let x = 0; x < array.length; x++) {
  // Codice
}

Possiamo anche usare la notazione for-in per leggere gli indici degli array:

const a = [1,2,3,4,5,6] 
for (let index in a) { 
   console.log(a[index]) 
}

Possiamo anche utilizzare la notazione for-of per ottenere direttamente le proprietà enumerabili di un array, ovvero i suoi valori diretti:

const a = [1,2,3,4,5,6] 
for (let item of a) { 
   console.log(item) 
}

Tutti i metodi descritti sono sincroni. Allora come fare? Grazie alla magia dell’iterazione asincrona, possiamo fare quanto segue:

const fs = require('fs');
const readline = require('readline');

// Specifica il percorso del file
const filePath = 'example.txt';

// Crea un flusso di lettura dal file
const fileStream = fs.createReadStream(filePath);

// Crea un'interfaccia readline
const rl = readline.createInterface({
    input: fileStream,
    crlfDelay: Infinity // Gestisce i ritorni a capo in modo corretto
});

// Funzione asincrona per leggere il file riga per riga
async function readLines() {
    try {
        for await (const line of rl) {
            console.log(`Linea: ${line}`);
        }
        console.log('Lettura del file completata.');
    } catch (error) {
        console.error(`Errore durante la lettura del file: ${error.message}`);
    }
}

// Avvia la lettura delle righe
readLines();

for wait e Node.js

La notazione for await è supportata nel runtime Node.js a partire dalla versione 10.x. Per le versioni 8.x o 9.x, bisogna eseguire il file JavaScript con l’estensione --harmony_async_iteration. Sfortunatamente, gli iteratori asincroni non sono supportati nelle versioni 6 e 7 di Node.js.

Per comprendere il concetto di iteratori asincroni, dobbiamo dare un’occhiata a cosa sono gli iteratori sincroni. Un iteratore sincrono è un oggetto che espone una funzione next() che restituisce un altro oggetto con la notazione {value: any, done: boolean}, dove value è il valore dell’iterazione corrente, e done determina se ci sono altri valori nella sequenza.

Un semplice esempio è un iteratore che scorre tutti gli elementi di un array, benchè un array è di per se già iterabile e quindi basterebbe tulizzare un semplice ciclo for:

const array = [1, 2, 3];
let index = 0;

const iterator = {
    next: () => {
        if (index >= array.length) {
            return { done: true }; // Indica che l'iterazione è completata
        }
        return { value: array[index++], done: false }; // Restituisce il valore corrente e segnala che non è finito
    }
};

A questo punto, possiamo utilizzarlo nel seguente modo, in modo da scorrere tutti gli elementi dell’array:

let result = iterator.next();
while (!result.done) {
    console.log(result.value); // Stampa il valore corrente
    result = iterator.next(); // Passa al valore successivo
}

Affichè l’iteratore supporti il costrutto for-of, bisogna modificare l’iteratore aggiungendo Symbol.iterator. Infatti un oggetto è considerato iterabile se possiede una proprietà con la chiave [Symbol.iterator] che restituisce un oggetto iteratore:

const array = [1, 2, 3];
let index = 0;

const iterator = {
    [Symbol.iterator]: function() {
        return this;
    },
    next: () => {
        if (index >= array.length) {
            return { done: true };
        }
        return { value: array[index++], done: false };
    }
};

// Utilizzo con for-of
for (const value of iterator) {
    console.log(value); // Stampa 1, 2, 3
}

Quindi, tutti gli array e gli oggetti hanno Symbol.iterator, in modo da poter eseguire for (let x of [1,2,3]) e restituire i valori di cui abbiamo bisogno.

Iterazione asincrona

Come ci si potrebbe aspettare, un iteratore asincrono funziona più o meno allo stesso modo di un iteratore normale. Solo che invece Symbol.iterator di abbiamo Symbol.asyncIterator, e invece di un oggetto che ritorna la coppia {value, done}, abbiamo una Promise che si risolve in un oggetto con la stessa firma.

Trasformiamo il nostro iteratore sopra in un iteratore asincrono:

const array = [1, 2, 3];
let index = 0;
const asyncIterator = {
  next: () => {
    if (index >= array.length) {
      return Promise.resolve({ done: true });
    }
    return Promise.resolve({ value: array[index++], done: false });
  }
};
const asyncIterable = {
  [Symbol.asyncIterator]: () => asyncIterator
};
  • asyncIterator è un oggetto che simula un iteratore asincrono. La sua funzione next restituisce una promessa che risolve in un oggetto con due proprietà:
    • value: il valore corrente dell’array.
    • done: un booleano che indica se l’iterazione è completa (true se non ci sono più valori).
  • Se l’indice corrente è maggiore o uguale alla lunghezza dell’array, restituisce una promessa risolta con { done: true }, altrimenti restituisce una promessa risolta con { value: array[index++], done: false }.
  • asyncIterable è un oggetto che implementa il protocollo dell’iteratore asincrono tramite la proprietà [Symbol.asyncIterator]. Questo metodo restituisce l’oggetto asyncIterator, che ha il metodo next definito come una funzione asincrona.

Per utilizzare l’iteratore asincrono, puoi utilizzare il costrutto for-await-of, che è simile a for-of ma funziona con iterabili asincroni.

(async () => {
  for await (const value of asyncIterable) {
    console.log(value); // Stampa 1, 2, 3
  }
})();

Gestione degli errori

Cosa succede se la nostra Promise viene rifiutata all’interno di un iteratore? Come con qualsiasi altra promessa rifiutata, possiamo individuare l’errore con un semplice try/catch(visto che stiamo usando await). La gestione degli errori negli iteratori asincroni in JavaScript è fondamentale per garantire che il codice sia robusto e in grado di gestire situazioni impreviste.

Ricordiamo che un iteratore asincrono è un oggetto che implementa il metodo Symbol.asyncIterator, il quale restituisce un iteratore che gestisce le operazioni di iterazione attraverso il metodo next(), che restituisce una Promise. Questo permette di utilizzare for await...of per iterare sui valori in modo asincrono. In tal caso,è possibile gestire gli errori all’interno del ciclo utilizzando un blocco try...catch che catturerà qualsiasi errore che si verifica durante la valutazione dell’iteratore asincrono.

const asyncIterable = {
  async *[Symbol.asyncIterator]() {
    yield await Promise.resolve(1);
    throw new Error("Errore durante l'iterazione");
    yield await Promise.resolve(2);
  }
};

(async () => {
  try {
    for await (const value of asyncIterable) {
      console.log(value);
    }
  } catch (error) {
    console.error("Errore catturato:", error);
  }
})();

Gestione degli errori nel metodo next()

Se abbiamo bisogno di gestire gli errori direttamente all’interno dell’implementazione del metodo next(), possiamo farlo restituendo una Promise che si risolve con un oggetto { value, done } o si rigetta con un errore.

const asyncIterable = {
  [Symbol.asyncIterator]() {
    return {
      i: 0,
      async next() {
        if (this.i < 3) {
          this.i++;
          return { value: this.i, done: false };
        } else {
          throw new Error("Iterazione fallita");
        }
      }
    };
  }
};

(async () => {
  try {
    for await (const value of asyncIterable) {
      console.log(value);
    }
  } catch (error) {
    console.error("Errore catturato:", error);
  }
})();

Gestione degli errori con finally

Possiamo anche utilizzare il blocco finally per eseguire del codice indipendentemente dal fatto che si sia verificato un errore o meno, utile per fare pulizia o altre operazioni finali.

(async () => {
  try {
    for await (const value of asyncIterable) {
      console.log(value);
    }
  } catch (error) {
    console.error("Errore catturato:", error);
  } finally {
    console.log("Iterazione completata");
  }
})();

Utilizzo di return() e throw() nell’iteratore asincrono

Gli iteratori asincroni possono anche implementare i metodi opzionali return() e throw() per gestire la chiusura dell’iterazione o propagare gli errori in modo più personalizzato.

  • return(value): questo metodo è chiamato quando l’iterazione è terminata anticipatamente (ad esempio, con un break).
  • throw(error): questo metodo è chiamato se viene sollevato un errore durante l’iterazione.
const asyncIterable = {
  async *[Symbol.asyncIterator]() {
    try {
      yield 1;
      yield 2;
      yield 3;
    } finally {
      console.log("Iteratore chiuso");
    }
  }
};

(async () => {
  try {
    for await (const value of asyncIterable) {
      console.log(value);
      if (value === 2) break;  // Terminazione anticipata
    }
  } catch (error) {
    console.error("Errore catturato:", error);
  }
})();

In questo esempio, il metodo finally assicura che il messaggio “Iteratore chiuso” venga stampato indipendentemente da come si termina l’iterazione.

Generatori asincroni

I generatori asincroni in JavaScript combinano la potenza dei generatori con l’elaborazione asincrona, permettendo di gestire flussi di dati che possono richiedere del tempo per essere elaborati. In pratica, un generatore asincrono è una funzione che restituisce un oggetto che implementa l’interfaccia di un iteratore asincrono.

Un generatore asincrono si definisce utilizzando la sintassi async function*. All’interno di questa funzione, possiamo usare l’operatore await per attendere il completamento di operazioni asincrone prima di restituire un valore con yield.

async function* asyncGenerator() {
  yield await Promise.resolve(1);
  yield await Promise.resolve(2);
  yield await Promise.resolve(3);
}

(async () => {
  for await (const value of asyncGenerator()) {
    console.log(value);
  }
})();

In questo esempio, la funzione asyncGenerator restituisce un generatore asincrono e si usa await per risolvere promesse prima di restituire ciascun valore.

Per iterare sui valori generati da un generatore asincrono, si utilizza il ciclo for await...of, che è simile al ciclo for...of ma è specifico per iterare sugli iteratori asincroni.

async function* fetchUrls(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    const data = await response.json();
    yield data;
  }
}

(async () => {
  const urls = ['https://api.example.com/data1', 'https://api.example.com/data2'];
  
  for await (const data of fetchUrls(urls)) {
    console.log(data);
  }
})();

In questo esempio, il generatore asincrono fetchUrls itera su un array di URL, esegue una richiesta fetch per ciascun URL, e restituisce i dati JSON ottenuti.