Segui su:
Programmazione Web Italia

Cosa sono i Generator in JavaScript? Scopri tutte le potenzialità dei Generatori!

Cosa sono i Generator in JavaScript? Scopri tutte le potenzialità dei Generatori!

I generatori JavaScript sono poco conosciuti all'interno del panorama degli sviluppatori Web, ma una volta compreso il loro funzionamento offrono un notevole vantaggio nella gestione del codice e del flusso di dati. Dai concetti di base ai casi più specifici, in questo articolo si descriveranno le caratteristiche salienti e si proporranno differenti esempi pratici questo particolare strumento. Marisa dice...

Introduzione

Introduzione alle funzioni generatrici in JavaScript

Le funzioni generatrici (generators) sono state introdotte con ECMAScript 6 (ES6), la loro particolarità principale è riferita al fatto che l'esecuzione del loro codice può essere interrotta e ripresa più volte generando e restituendo valori "intermedi" al metodo chiamante. Grazie alla loro capacità di sospendere e riprendere l'esecuzione, sono una scelta ottimale nel caso di iterazioni complesse, flussi di dati, implementazioni asincrone o quando si lavora con sequenze di valori o calcoli "on demand".

Differenza tra una funzione ordinaria e una funzione generatrice

A differenza di una normale funzione, che restituisce un solo valore al termine della sua esecuzione, i generatori JavaScript restituiscono molteplici valori mano a mano che il loro codice viene eseguito.

Cos'è una funzione generatrice?

Casi d'uso dei generatori in Javascript

Le funzioni generatrici hanno campi di applicazione ben definiti: sono utili nel caso si debba generare una grande quantità di dati o sequenze potenzialmente infinite senza occupare tutta la memoria immediatamente. Alcuni casi in cui l'utilizzo dei generators si rivela particolarmente utile comprendono i seguenti...

  • Iterazioni su sequenze di dati infinite (ad esempio, numeri interi, stream di dati).
  • Lazy evaluation: calcolare valori solo quando necessario.
  • Implementazione di algoritmi complessi che richiedono l'interruzione e la ripresa del calcolo.
  • Simulazione di stati in algoritmi asincroni o di simulazione, dove è utile "mettere in pausa" e "riprendere" il calcolo in momenti specifici.

Sintassi dei generatori JavaScript

Una funzione generatrice viene definita attraverso una sintassi simile a quella che definisce una funzione tradizionale, ma in questo caso si utilizza la parola chiave function*. All'interno del corpo della funzione la parola chiave yield comunica all'interprete JS di mettere in "pausa" l'esecuzione e di restituire il valore specificato (tipicamente il risultato della computazione ottenuto fino a quell'istante). Una chiamata successiva al generatore permetterà, in seguito, di riprenderne l'esecuzione.
Di seguito un semplice esempio di funzione generativa.

Un semplice esempio di generatore in JavaScript

  function* generatoreNumeri() {
    yield 1;
    yield 2;
    yield 3;
  }
 
  const gen = generatoreNumeri();
 
  console.log(gen.next()); // { value: 1, done: false }
  console.log(gen.next()); // { value: 2, done: false }
  console.log(gen.next()); // { value: 3, done: false }
  console.log(gen.next()); // { value: undefined, done: true }

Caratteristiche principali

La chiamata di una funzione generatore restituisce un oggetto iteratore, questo oggetto espone il metodo next(), che consente di ottenere il valore successivo generato dalla funzione. Ogni chiamata a next() riprende l'esecuzione della funzione da dove si era fermata. Il metodo next() ha due proprietà: value e done. Il primo contiene il valore prodotto dalla funzione generatrice tramite yield, il secondo è un booleano che indica se la funzione ha terminato l'esecuzione.

Le funzioni generatrici sono definite come lazily in quanto eseguono il loro codice in modo ritardato (lazy): il codice viene eseguito solo quando richiesto, producendo un valore alla volta tramite la chiamata a yield.

Mantenimento dello stato

Una caratteristica importante dei generatori (e un caratteristica che li accomuna alle closure) è quella di mantenere lo stato tra le successive chiamate al metodo next(). Qui sotto un semplice esempio descrittivo.

Il generator è capace di mantenere lo stato tra chiamate successive

function* contatore() {
  let i = 0;
  while (true) {
    yield i++;
  }
}
 
const gen = contatore();
console.log(gen.next().value); // 0
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2

Passaggio di valori nei generatori

E' possibile inviare dati al generatore tramite una chiamata metodo next() e l'aggiunta di un argomento. Quando si chiama il metodo next(val) su un iteratore il valore viene passato al generatore nel punto immediatamente successivo all'ultima dichiarazione yield. Questo è utile per controllare dinamicamente il comportamento del generatore o per alimentarlo con nuovi dati man mano che l'iterazione procede.

Passaggio di argomenti al generatore

function* sommatore() {
  let somma = 0;
  let incremento = yield somma; // Attende un valore da 'next()'
  while (true) {
      somma += incremento;
      incremento = yield somma;  // Riceve il nuovo valore da 'next()'
  }
}
 
const iteratore = sommatore();
 
console.log(iteratore.next().value);  // 0, inizialmente il generatore restituisce 0
console.log(iteratore.next(5).value); // 5, passa 5 al generatore e restituisce la somma
console.log(iteratore.next(3).value); // 8, somma 3 al precedente valore 5 e restituisce 8
console.log(iteratore.next(10).value); // 18, somma 10 al precedente valore 8 e restituisce 18

La possibilità di passare parametri permette di influenzare in modo interattivo il comportamento del generatore durante l'esecuzione. I risultati ottenuti dipendono dagli input esterni e rendono i generatori adatti a situazioni in cui i dati devono essere calcolati progressivamente e su richiesta.

Gestione degli errori nei generatori

Esistono diversi metodi per gestire gli errori all'interno di un generatore, tra cui l'uso di try...catch e l'invio di errori nel generatore stesso tramite il metodo throw().

Uso di try...catch all'interno di un generatore

Per gestire l'eventualità di un errore durante l'esecuzione di codice all'interno di un generatore, è possibile includere un blocco try...catch all'interno della stessa. Sarà così possibile la cattura e la gestione delle eccezzioni direttamente all'interno della funzione generatrice.

Cattura e gestione di una eccezione all'interno di una funzione generatore

function* generatoreConErrore() {
  try {
      yield 1;
      yield 2;
      throw new Error("Errore intenzionale!"); // Genera un errore
      yield 3; // Questo non verrà mai raggiunto
  } catch (error) {
      console.log("Errore catturato nel generatore:", error.message);
  }
}
const gen = generatoreConErrore();
 
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // Errore catturato nel generatore: Errore intenzionale!
console.log(gen.next()); // { value: undefined, done: true }

Uso del metodo throw()

E' anche possibile inviare eccezioni a un generatore dall'esterno utilizzando il metodo throw() dell'oggetto iteratore. L'eccezione viene inviata al generatore tramite il metodo gen.throw(), dopo che l'errore è stato gestito l'esecuzione riprende dal punto in cui era stata interrotta. In questo modo è possibile interagire con il flusso di esecuzione del generatore e consentire a codice esterno di segnalare errori o eventi in modo tale che sia il generatore stesso a gestirli.

Invio di un errore ad un generatore e gestione dello stesso

function* generatore() {
  try {
      yield 1;
      yield 2;
  } catch (error) {
      console.log("Errore catturato:", error.message);
  }
  yield 3;
}
 
const gen = generatore();
 
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
gen.throw(new Error("Errore esterno!")); // Errore catturato: Errore esterno!
console.log(gen.next()); // { value: 3, done: false }

Uso combinato di generatori e iteratori

Il ciclo for...of può essere utilizzato in combinazione con i generatori, in questo caso la chiamata a next() è implicita nel ciclo che continuera il suo loop fino a quando il generatore non restituirà il parametro done con valore true.

Ciclo for...of e una funzione generatore

function* contatore(max) {
  let i = 0;
  while (i < max) {
      yield i++;
  }
}
for (let num of contatore(5)) {
  console.log(num); // Stampa: 0, 1, 2, 3, 4
}

Generatori e asincronia

I generatori asincroni gestiscono l'iterazione su valori ottenuti in modo asincrono: richieste di rete; operazioni di I/O o altre operazioni che richiedono tempo per essere completate. Una funzione generatrice asincrona è definita usando la sintassi async function* e, come i generatori normali, utilizza yield per produrre valori asincroni.
Vediamo un esempio di generatore asincrono in combinazione con un ciclo for...of.

Ciclo for...of e una funzione generatore asincrona

async function* generatoreAsincrono() {
    yield new Promise(resolve => setTimeout(() => resolve('Valore 1'), 1000));
    yield new Promise(resolve => setTimeout(() => resolve('Valore 2'), 2000));
    yield new Promise(resolve => setTimeout(() => resolve('Valore 3'), 3000));
}
 
async function processaGeneratoriAsincroni() {
    for await (let valore of generatoreAsincrono()) {
        console.log(valore); // Stampa "Valore 1", "Valore 2", "Valore 3" con intervalli
    }
}
 
processaGeneratoriAsincroni();

I generatori asincroni e il ciclo for await...of rappresentano un metodo molto potente per la gestione di operazioni asincrone in JavaScript: richieste API sequenziali, streaming di dati... .

Casi d'uso pratici: un generatore per gestire valori potenzialmente infiniti

I valori sono elaborati dai generatori solo dopo una chiamata esplicita, quindi è possibile gestire flussi di dati teoricamente infiniti senza il pericolo di saturare le risorse del sistema.
Ecco un esempio pratico.

Un generatore che gestisce in maniera efficente flussi di dati infiniti

function* infinite() {
  let index = 0;
 
  while (true) {
    yield index++;
  }
}
 
const generator = infinite(); // "Generator { }"
 
console.log(generator.next().value); // 0
console.log(generator.next().value); // 1
console.log(generator.next().value); // 2
// …

Conclusione

I generatori offrono un controllo granulare sul flusso di esecuzione e sono ideali per gestire in modo efficiente operazioni complesse o asincrone, migliorando la gestione della memoria e semplificando la struttura del codice, ma è consigliabile usare i generatori solo nei casi in cui ci si aspetta di gestire flussi di dati complessi o sequenze potenzialmente infinite, come dati in streaming o iterazioni complesse, evitando di usarli per operazioni semplici in cui una normale funzione può bastare.

In conclusione i vantaggi dei generatori possono essere riassunti nella seguente lista.

  • Esecuzione lazy
  • Controllo dello stato interno
  • Gestione semplificata delle iterazioni
  • Chiarezza del codice
  • Miglior gestione dei flussi asincroni