Funzioni Normali vs GeneratoriNota la freccia tratteggiata che chiude il ciclo rendimento-resume-rendimento appena prima di Fine nella parte Generatori dell’immagine. C’è la possibilità che un generatore non finisca mai. Vedremo un esempio più tardi.
Creazione di un generatore
Vediamo come possiamo creare un generatore in JavaScript –
function * generatorFunction() { // Line 1
console.log('This will be executed first.');
yield 'Hello, '; // Line 2 console.log('I will be printed after the pause');
yield 'World!';
}const generatorObject = generatorFunction(); // Line 3console.log(generatorObject.next().value); // Line 4
console.log(generatorObject.next().value); // Line 5
console.log(generatorObject.next().value); // Line 6// This will be executed first.
// Hello,
// I will be printed after the pause
// World!
// undefined
Focalizzatevi sulle parti in grassetto. Per creare una funzione generatrice, usiamo la sintassi function *
invece che solo function
. Qualsiasi numero di spazi può esistere tra la parola chiave function
, il *
e il nome della funzione. Dal momento che è solo una funzione, potete usarla ovunque possa essere usata una funzione, cioè dentro oggetti e metodi di classe.
All’interno del corpo della funzione, non abbiamo un return
. Invece, abbiamo un’altra parola chiave yield
(linea 2). È un operatore con cui un generatore può mettere in pausa se stesso. Ogni volta che un generatore incontra un yield
, “restituisce” il valore specificato dopo di esso. In questo caso, viene restituito Hello,
. Tuttavia, non diciamo “restituito” nel contesto dei generatori. Diciamo “il generatore ha prodotto Hello,
“.
Possiamo anche tornare da un generatore. Tuttavia, return
imposta la proprietà done
a true
dopo di che il generatore non può generare altri valori.
function * generatorFunc() {
yield 'a';
return 'b'; // Generator ends here.
yield 'a'; // Will never be executed.
}
Nella linea 3, creiamo l’oggetto generatore. Sembra che stiamo invocando la funzione generatorFunction
. Infatti è così! La differenza è che invece di restituire qualsiasi valore, una funzione generatore restituisce sempre un oggetto generatore. L’oggetto generatore è un iteratore. Quindi potete usarlo nei cicli for-of
o in altre funzioni che accettano un iterabile.
Nella linea 4, chiamiamo il metodo next()
sul generatorObject
. Con questa chiamata, il generatore inizia l’esecuzione. Prima, console.log
il This will be executed first.
Poi, incontra un yield 'Hello, '
. Il generatore restituisce il valore come oggetto { value: 'Hello, ', done: false }
e sospende/pausa. Ora, è in attesa della prossima invocazione.
Nella linea 5, chiamiamo di nuovo next()
. Questa volta il generatore si sveglia e inizia l’esecuzione da dove aveva lasciato. La prossima linea che trova è una console.log
. Registra la stringa I will be printed after the pause
. Viene incontrato un altro yield
. Il valore è reso come l’oggetto { value: 'World!', done: false }
. Estraiamo la proprietà value
e la registriamo. Il generatore dorme di nuovo.
Nella linea 6, invochiamo di nuovo next()
. Questa volta non ci sono più linee da eseguire. Ricordate che ogni funzione restituisce implicitamente undefined
se non viene fornita una dichiarazione di ritorno. Quindi, il generatore restituisce (invece di restituire) un oggetto { value: undefined, done: true}
. Il done
è impostato a true
. Questo segnala la fine di questo generatore. Ora, non può generare altri valori o riprendere di nuovo poiché non ci sono più istruzioni da eseguire.
Abbiamo bisogno di creare un altro oggetto generatore per eseguire di nuovo il generatore.
Usi dei generatori
Ci sono molti fantastici casi d’uso dei generatori. Vediamone alcuni.
Implementazione degli iterabili
Quando implementate un iteratore, dovete creare manualmente un oggetto iteratore con un metodo next()
. Inoltre, devi salvare manualmente lo stato. Spesso, diventa davvero difficile farlo. Poiché i generatori sono anche iterabili, possono essere usati per implementare gli iterabili senza il codice extra di boilerplate. Vediamo un semplice esempio.
Problema: Vogliamo fare un iterabile personalizzato che restituisca This
, is
e iterable.
. Ecco un’implementazione usando gli iteratori –
const iterableObj = {
() {
let step = 0;
return {
next() {
step++;
if (step === 1) {
return { value: 'This', done: false};
} else if (step === 2) {
return { value: 'is', done: false};
} else if (step === 3) {
return { value: 'iterable.', done: false};
}
return { value: '', done: true };
}
}
},
}for (const val of iterableObj) {
console.log(val);
}// This
// is
// iterable.
Ecco la stessa cosa usando i generatori –
function * iterableObj() {
yield 'This';
yield 'is';
yield 'iterable.'
}for (const val of iterableObj()) {
console.log(val);
}// This
// is
// iterable.
Puoi confrontare entrambe le versioni. È vero che questo è un esempio un po’ artificioso. Ma illustra i punti –
- Non dobbiamo preoccuparci di
Symbol.iterator
- Non dobbiamo implementare
next()
.
- Non dobbiamo fare manualmente l’oggetto di ritorno di
next()
cioè { value: 'This', done: false }
.
- Non dobbiamo salvare lo stato. Nell’esempio dell’iteratore, lo stato è stato salvato nella variabile
step
. Il suo valore definiva ciò che veniva emesso dall’iterabile. Non abbiamo dovuto fare nulla del genere nel generatore.
Migliore funzionalità asincrona
Il codice che usa promesse e callback come –
function fetchJson(url) {
return fetch(url)
.then(request => request.text())
.then(text => {
return JSON.parse(text);
})
.catch(error => {
console.log(`ERROR: ${error.stack}`);
});
}
può essere scritto come (con l’aiuto di librerie come co.js)-
const fetchJson = co.wrap(function * (url) {
try {
let request = yield fetch(url);
let text = yield request.text();
return JSON.parse(text);
}
catch (error) {
console.log(`ERROR: ${error.stack}`);
}
});
Alcuni lettori potrebbero aver notato che è parallelo all’uso di async/await
. Questa non è una coincidenza. async/await
può seguire una strategia simile e sostituisce il rendimento con await
nei casi in cui sono coinvolte promesse. Può essere basato su generatori. Vedi questo commento per maggiori informazioni.
Flussi di dati infiniti
È possibile creare generatori che non finiscono mai. Considerate questo esempio –
function * naturalNumbers() {
let num = 1;
while (true) {
yield num;
num = num + 1
}
}const numbers = naturalNumbers();console.log(numbers.next().value)
console.log(numbers.next().value)// 1
// 2
Facciamo un generatore naturalNumbers
. All’interno della funzione, abbiamo un ciclo infinito while
. In questo ciclo, facciamo yield
il num
. Quando il generatore cede, viene sospeso. Quando chiamiamo di nuovo next()
, il generatore si sveglia, continua da dove era stato sospeso (in questo caso yield num
) ed esegue fino a quando si incontra un altro yield
o il generatore finisce. Poiché la prossima dichiarazione è num = num + 1
, aggiorna num
. Poi, va all’inizio del ciclo while. La condizione è ancora vera. Incontra la prossima linea yield num
. Cede il num
aggiornato e sospende. Questo continua finché volete.
Generatori come osservatori
I generatori possono anche ricevere valori usando la funzione next(val)
. Allora il generatore è chiamato osservatore, poiché si sveglia quando riceve nuovi valori. In un certo senso, continua ad osservare i valori e agisce quando ne riceve uno. Puoi leggere di più su questo modello qui.
Svantaggi dei generatori
Come visto nell’esempio di Infinite Data Streams, è possibile solo grazie alla valutazione pigra. La valutazione pigra è un modello di valutazione che ritarda la valutazione di un’espressione finché il suo valore non è necessario. Cioè, se non abbiamo bisogno del valore, non esiste. Viene calcolato man mano che lo richiediamo. Vediamo un esempio –
function * powerSeries(number, power) {
let base = number;
while(true) {
yield Math.pow(base, power);
base++;
}
}
Il powerSeries
dà la serie del numero elevato ad una potenza. Per esempio, la serie di potenza di 3 elevato a 2 sarebbe 9(3²) 16(4²) 25(5²) 36(6²) 49(7²). Quando facciamo const powersOf2 = powerSeries(3, 2);
creiamo solo l’oggetto generatore. Nessuno dei valori è stato calcolato. Ora, se chiamiamo next()
, 9 verrebbe calcolato e restituito.
Efficiente in termini di memoria
Una conseguenza diretta della Lazy Evaluation è che i generatori sono efficienti in termini di memoria. Generiamo solo i valori che sono necessari. Con le funzioni normali, avevamo bisogno di pre-generare tutti i valori e tenerli in giro nel caso li avessimo usati in seguito. Tuttavia, con i generatori, possiamo rimandare il calcolo finché non ne abbiamo bisogno.
Possiamo creare funzioni combinatorie per agire sui generatori. I combinatori sono funzioni che combinano iterabili esistenti per crearne di nuovi.Uno di questi combinatori è take
. Prende i primi n
elementi di un iterabile. Ecco un’implementazione –
function * take(n, iter) {
let index = 0;
for (const val of iter) {
if (index >= n) {
return;
}
index = index + 1;
yield val;
}
}
Ecco alcuni casi d’uso interessanti di take
–
take(3, )// a b ctake(7, naturalNumbers());// 1 2 3 4 5 6 7take(5, powerSeries(3, 2));// 9 16 25 36 49
Ecco un’implementazione di libreria ciclica (senza la funzionalità di inversione).
function * cycled(iter) {
const arrOfValues =
while (true) {
for (const val of arrOfValues) {
yield val
}
}
}console.log(...take(10, cycled(take(3, naturalNumbers()))))// 1 2 3 1 2 3 1 2 3 1
Caveat
Ci sono alcuni punti che dovresti ricordare mentre programmi usando i generatori.
- Gli oggetti generatore sono solo ad accesso unico. Una volta che avete esaurito tutti i valori, non potete iterare di nuovo. Per generare nuovamente i valori, è necessario creare un nuovo oggetto generatore.
const numbers = naturalNumbers();console.log(...take(10, numbers)) // 1 2 3 4 5 6 7 8 9 10
console.log(...take(10, numbers)) // This will not give any data
- Gli oggetti generatori non permettono un accesso casuale come quello possibile con gli array. Poiché i valori sono generati uno per uno, l’accesso a un valore casuale porterebbe al calcolo dei valori fino a quell’elemento. Quindi, non è un accesso casuale.
Conclusione
Molte cose devono ancora essere coperte nei generatori. Cose come yield *
, return()
e throw()
. I generatori rendono anche possibili le coroutine. Ho elencato alcuni riferimenti che potete leggere per comprendere meglio i generatori.
Potete andare alla pagina itertools di Python, e vedere alcune delle utility che permettono di lavorare con iteratori e generatori. Come esercizio, potete implementare voi stessi le utilità.
Sì.