30 aprile 2022

I generatori

Le funzioni ritornano normalmente un solo valore (a volte non ritornano nulla).

I generatori possono ritornare (“yield”) valori multipli, uno dopo l’altro, ogni volta che vengono invocati. Sono, di fatto, lo strumento ideale da utilizzare con gli iteratori, dal momento che ci consentono di creare flussi di dati con facilità.

Le funzioni generatrici

Per creare un generatore, abbiamo bisogno di uno specifico costrutto sintattico: function*, chiamato, appunto, “funzione generatrice”.

Ecco un esempio:

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

Le funzioni generatrici si comportano diversamente rispetto alle normali funzioni. Quando una generatrice viene invocata, di fatto, il codice al suo interno non viene eseguito, ma ritorna uno speciale oggetto, chiamato “oggetto generatore”, che ne consente di gestire l’esecuzione.

Dai un’occhiata qua:

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

// "la funzione generatrice" crea un "oggetto generatore"
let generator = generateSequence();
alert(generator); // [object Generator]

L’esecuzione del codice della funzione non è ancora iniziata:

Il metodo principale di un oggetto generatore è next(). Quando invocato, esegue le istruzioni in esso contenute fino alla prossima istruzione yield <valore> (valore può essere omesso, in tal caso sarà undefined). A questo punto l’esecuzione si arresta e il valore viene “ceduto” al codice esterno.

Il risultato dell’esecuzione di next() è sempre un oggetto con due proprietà:

  • value: il valore che viene “ceduto”.
  • done: true, se il codice della funzione è stato eseguito completamente, altrimenti, false.

Ad esempio, qui andiamo a creare un generatore e otteniamo il primo valore “ceduto”:

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

let generator = generateSequence();

let one = generator.next();

alert(JSON.stringify(one)); // {value: 1, done: false}

A questo punto, abbiamo ottenuto solo il primo valore e l’esecuzione della funzione ha raggiunto la seconda riga:

Invochiamo ancora generator.next(). L’esecuzione del codice riprenderà da dove si era fermata, fino a restituire il valore del prossimo yield:

let two = generator.next();

alert(JSON.stringify(two)); // {value: 2, done: false}

Per finire, invocandolo nuovamente (generator.next()), si raggiungerà l’istruzione return che terminerà la funzione:

let three = generator.next();

alert(JSON.stringify(three)); // {value: 3, done: true}

A questo punto il generatore ha terminato. Possiamo vederlo dal risultato finale done:true e value:3.

Effettuare nuove chiamate a generator.next() non avrebbe più senso. Se lo facciamo, otterremmo sempre lo stesso oggetto: {done: true}.

function* f(…)orfunction *f(…)?

Entrambe le sintassi sono corrette.

La prima, tuttavia, è la più utilizzata, dal momento che è l’asterisco * a indicare che la funzione è una generatrice, non il nome. Per questo motivo ha più senso accoppiare l’asterisco con la parola chiave function.

I generatori sono iteratori

Come probabilmente avrai intuito dalla presenza del metodo next(), i generatori sono iterabili.

Possiamo eseguire cicli sui valori ritornati utilizzando for..of:

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

let generator = generateSequence();

for(let value of generator) {
  alert(value); // 1, then 2
}

Non trovi che sia molto più leggibile di .next().value?

…Nota bene: l’esempio precedente produrrà come risultato 1 e 2, nulla più. Il 3 non verrà preso in considerazione!

La spiegazione di questo comportamento sta nel fatto che for..of ignora l’ultimo value non appena la proprietà è done: true. Per questo motivo, se vogliamo che tutti i valori siano mostrati da for..of, dobbiamo ritornarli tramite yield:

function* generateSequence() {
  yield 1;
  yield 2;
  yield 3;
}

let generator = generateSequence();

for(let value of generator) {
  alert(value); // 1, poi 2, poi 3
}

Dal momento che i generatori sono iteratori, possiamo usufruire di tutte le funzionalità che ne derivano, per esempio lo “spread operator”.

function* generateSequence() {
  yield 1;
  yield 2;
  yield 3;
}

let sequence = [0, ...generateSequence()];

alert(sequence); // 0, 1, 2, 3

Nell’esempio precedente ...generateSequence() converte l’oggetto generatore iteratore in un array di elementi (puoi approfondire l’argomento relativo allo spread operator nel capitolo Articolo "rest-parameters-spread-operator" non trovato)

Usare i generatori con gli iteratori

Tempo fa, nel capitolo Iteratori abbiamo creato un oggetto iteratore range che ritorna valori from..to (da…a).

Ecco l’esempio, per rinfrescarci la memoria:

let range = {
  from: 1,
  to: 5,

  // for..of invoca questo metodo solo all'inizio
  [Symbol.iterator]() {
    // ...ritorna l'oggetto iteratore:
    // da qui in poi, for..of utilizzer&agrave; solo quell'oggetto per ottenere i valori successivi
    return {
      current: this.from,
      last: this.to,

      // next() viene invocata ad ogni iterazione fino alla fine del ciclo for..of
      next() {
        // dovrebbe ritornare il valore sotto forma di un oggett {done:..., value:...}
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      },
    };
  },
};

// l'iterazione su range ritorna i numeri compresi tra range.from e range.to
alert([...range]); // 1,2,3,4,5

Possiamo usare una funzione generatrice come iteratore assegnandola a Symbol.iterator.

Ecco qui lo stesso range, ma in una forma più compatta:

let range = {
  from: 1,
  to: 5,

  *[Symbol.iterator]() {
    // forma abbreviata di [Symbol.iterator]: function*()
    for (let value = this.from; value <= this.to; value++) {
      yield value;
    }
  },
};

alert([...range]); // 1,2,3,4,5

Il funzionamento è invariato dal momento che range[Symbol.iterator]() ora ritorna un generatore che è esattamente quello che for..of si aspetta:

  • ha un metodo .next()
  • il quale ritorna valori nella forma {value: ..., done: true/false}

Questa non è una coincidenza, ovviamente. I generatori sono stati aggiunti al linguaggio JavaScript tenendo a mente gli iteratori, per implementarli più facilmente.

La variante con i generatori è molto più concisa del codice originale di range ma mantiene le funzionalità invariate.

I generatori potrebbero generare valori per sempre

Negli esempi precedenti abbiamo generato sequenze finite ma possiamo anche creare generatori che restituiscono valori infinitamente. Per esempio, una sequenza infinita di numeri pseudo-casuali.

Ciò richiederebbe sicuramente un break (o un return) nel for..of che utilizziamo per iterare su tale generatore, altrimenti il ciclo si ripeterebbe all’infinito bloccando l’esecuzione dell’applicazione.

Composizione di generatori

La composizione dei generatori è una caratteristica particolare dei generatori che consente di “innestarli” l’uno nell’altro, in modo trasparente.

Per esempio, data una funzione che genera una sequenza di numeri:

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) yield i;
}

Vogliamo usarla per generare una sequenza più complessa:

  • per prime le cifre 0..9 (con i codici carattere 48…57),
  • seguite dalle lettere dell’alfabeto a..z (codici carattere 65…90)
  • seguite dalle lettere maiuscole A..Z (codici carattere 97…122)

Possiamo usare questa sequenza, ad esempio, per creare password selezionandone i caratteri (potremmo anche aggiungere caratteri sintattici), ma generiamo la sequenza per ora.

In una normale funzione, per combinare i risultati di più funzioni, dapprima invochiamo le funzioni, ne memorizziamo i risultati e, infine, li combiniamo.

Usando i generatori, c’è una sintassi speciale di yield* per “innestare” (comporre) un generatore all’interno di un altro.

Il generatore composto:

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) yield i;
}

function* generatePasswordCodes() {

  // 0..9
  yield* generateSequence(48, 57);

  // A..Z
  yield* generateSequence(65, 90);

  // a..z
  yield* generateSequence(97, 122);

}

let str = '';

for(let code of generatePasswordCodes()) {
  str += String.fromCharCode(code);
}

alert(str); // 0..9A..Za..z

La direttiva yield* delega l’esecuzione a un altro generatore. Con il termine delega si intende che yield* gen itera sui valori del generatore gen e, in modo trasparente, inoltra i valori che restituisce verso l’esterno. Come se i valori fossero restituiti dal generatore più esterno.

Il risultato è lo stesso che otterremmo mettendo in sequenza il codice dei generatori annidati:

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) yield i;
}

function* generateAlphaNum() {

  // yield* generateSequence(48, 57);
  for (let i = 48; i <= 57; i++) yield i;

  // yield* generateSequence(65, 90);
  for (let i = 65; i <= 90; i++) yield i;

  // yield* generateSequence(97, 122);
  for (let i = 97; i <= 122; i++) yield i;

}

let str = '';

for(let code of generateAlphaNum()) {
  str += String.fromCharCode(code);
}

alert(str); // 0..9A..Za..z

La composizione dei generatori è un modo naturale di immettere il flusso di un generatore all’interno di un altro. Non viene utilizzata memoria aggiuntiva per memorizzare i valori intermedi.

“yield” è una via a doppio senso

Finora i generatori sono assimilati agli iteratori, con una sintassi speciale per generare valori ma, di fatto, sono molto più potenti e flessibili.

Questo perché yield è una via a doppio senso: non solo ritorna il risultato verso l’esterno ma provvede anche a passare il valore all’interno del generatore.

Per fare questo, dovremmo invocare generator.next(arg) con un argomento. Tale argomento diventerà il risultato di yield.

Vediamo un esempio:

function* gen() {
  // Passa una domanda al codice esterno e attende una risposta
  let result = yield "2 + 2 = ?"; // (*)

  alert(result);
}

let generator = gen();

let question = generator.next().value; // <-- yield ritorna il valore

generator.next(4); // --> pass il risultato al generatore
  1. La prima invocazione di generator.next() è sempre senza argomento. Inizia l’esecuzione e ritorna il risultato della prima yield "2+2=?". A questo punto il generatore si arresta (si ferma su quella riga).
  2. Di seguito, come mostrato nella figura sopra, il risultato di yield va dentro alla variabile question nel codice all’esterno.
  3. Quando eseguiamo generator.next(4), il generatore riprende l’esecuzione e 4 finisce nel risultato: let result = 4.

Si noti che il codice all’esterno non deve per forza invocare next(4) immediatamente. Potrebbe impiegare del tempo, ma questo non è un problema: il generatore attenderà.

Per esempio:

// ripristina il generatore dopo un certo lasso di tempo
setTimeout(() => generator.next(4), 1000);

Come possiamo vedere, a differenza delle normali funzioni, un generatore e il codice che lo invoca possono scambiarsi risultati passando valori a next/yield.

Per rendere il tutto ancora più evidente, ecco un altro esempio, con più chiamate:

function* gen() {
  let ask1 = yield "2 + 2 = ?";

  alert(ask1); // 4

  let ask2 = yield "3 * 3 = ?";

  alert(ask2); // 9
}

let generator = gen();

alert(generator.next().value); // "2 + 2 = ?"

alert(generator.next(4).value); // "3 * 3 = ?"

alert(generator.next(9).done); // true

Illustrazione dell’esecuzione:

  1. La prima .next() inizia l’esecuzione… raggiunge la prima yield.
  2. Il risultato viene ritornato al codice esterno.
  3. La seconda .next(4) passa 4 al generatore come risultato della prima yield e riprende l’esecuzione.
  4. …la seconda yield viene raggiunta e diventa il risultato della chiamata al generatore.
  5. La terza next(9) passa 9 nel generatore come risultato della seconda yield e riprende l’esecuzione che raggiunge la fine della funzione, dunque, done: true.

È un po’ come una partita a “ping-pong”. Ogni next(value) (escluso il primo), passa un valore al generatore. Questo valore diventa il risultato della yield corrente per poi ritornare il risultato della yield successiva.

generator.throw

Come abbiamo visto negli esempi precedenti, il codice esterno può passare un valore all’interno del generatore, come risultato della yield.

…ma può anche passargli (throw) un errore. Ciò è naturale, dal momento che un errore è comunque un risultato.

Per passare un errore all’interno di una yield, dovremmo invocare generator.throw(err). In questo caso, tale err risulta generato dalla riga contenente tale yield.

Nel prossimo esempio, la yield di "2 + 2 = ?" genera un errore:

function* gen() {
  try {
    let result = yield "2 + 2 = ?"; // (1)

    alert("The execution does not reach here, because the exception is thrown above");
  } catch(e) {
    alert(e); // shows the error
  }
}

let generator = gen();

let question = generator.next().value;

generator.throw(new Error("The answer is not found in my database")); // (2)

L’errore, lanciato all’interno del generatore alla riga (2) genera un’eccezione alla riga (1) in corrispondenza della yield. Nell’esempio precedente try..catch gestisce l’errore e lo mostra.

Se non gestiamo l’errore, come accadrebbe con qualsiasi eccezione, quest’ultima andrebbe a causare un errore nel codice esterno.

La riga corrente del codice esterno è quella che contiene generator.throw, identificata da (2). Possiamo, pertanto, gestire l’eccezione qui, come nell’esempio:

function* generate() {
  let result = yield "2 + 2 = ?"; // Errore in questa riga
}

let generator = generate();

let question = generator.next().value;

try {
  generator.throw(new Error("The answer is not found in my database"));
} catch(e) {
  alert(e); // mostra l'errore
}

Se non gestiamo l’errore qui, come al solito, questo risalirà fino al codice più esterno, se esistente, altrimenti farà fallire lo script.

Riepilogo

  • I generatori vengono creati tramite funzioni generatrici function* f(…) {…}.
  • Solo nei generatori può esistere un operatore yield.
  • Il codice esterno e il generatore possono interscambiare risultato tramite chiamate a next/yield.

Nel JavaScript moderno, i generatori vengono usati raramente ma a volte possono essere utili, dal momento che la loro capacità di interscambiare dati con il codice all’esterno è alquanto unica. Sicuramente, sono un ottimo modo per creare degli iteratori.

Nel prossimo capitolo impareremo a usare i generatori asincroni, utilizzati per leggere flussi di dati generati in modo asincrono (per esempio, dati paginati ottenuti dalla rete) nei cicli for await ... of.

Questo è un caso d’uso molto importante, dal momento che nella programmazione web ci troviamo spesso a manipolare flussi di dati.

Esercizi

There are many areas where we need random data.

One of them is testing. We may need random data: text, numbers, etc. to test things out well.

In JavaScript, we could use Math.random(). But if something goes wrong, we’d like to be able to repeat the test, using exactly the same data.

For that, so called “seeded pseudo-random generators” are used. They take a “seed”, the first value, and then generate the next ones using a formula so that the same seed yields the same sequence, and hence the whole flow is easily reproducible. We only need to remember the seed to repeat it.

An example of such formula, that generates somewhat uniformly distributed values:

next = previous * 16807 % 2147483647

If we use 1 as the seed, the values will be:

  1. 16807
  2. 282475249
  3. 1622650073
  4. …and so on…

The task is to create a generator function pseudoRandom(seed) that takes seed and creates the generator with this formula.

Usage example:

let generator = pseudoRandom(1);

alert(generator.next().value); // 16807
alert(generator.next().value); // 282475249
alert(generator.next().value); // 1622650073

Apri una sandbox con i test.

function* pseudoRandom(seed) {
  let value = seed;

  while(true) {
    value = value * 16807 % 2147483647
    yield value;
  }

};

let generator = pseudoRandom(1);

alert(generator.next().value); // 16807
alert(generator.next().value); // 282475249
alert(generator.next().value); // 1622650073

Please note, the same can be done with a regular function, like this:

function pseudoRandom(seed) {
  let value = seed;

  return function() {
    value = value * 16807 % 2147483647;
    return value;
  }
}

let generator = pseudoRandom(1);

alert(generator()); // 16807
alert(generator()); // 282475249
alert(generator()); // 1622650073

That also works. But then we lose ability to iterate with for..of and to use generator composition, that may be useful elsewhere.

Apri la soluzione con i test in una sandbox.

Mappa del tutorial