ES6 introduceerde een nieuwe manier van werken met functies en iteratoren in de vorm van generatoren (of generatorfuncties). Een generator is een functie die halverwege kan stoppen en dan verder kan gaan vanaf het punt waar hij gestopt is. Kortom, een generator lijkt een functie, maar gedraagt zich als een iterator.
Fun Fact: async/await
kan gebaseerd zijn op generators. Lees hier meer.
Generators zijn ingewikkeld verbonden met iterators. Als je niet weet over iterators, hier is een artikel om uw begrip van hen te verbeteren.
Hier is een eenvoudige analogie om een intuïtie voor generatoren hebben voordat we verder gaan met de technische details.
Stel je voor dat je het lezen van een nagelbijtende techno-thriller. Helemaal opgaand in de pagina’s van het boek, hoor je nauwelijks de deurbel gaan. Het is de pizzabezorger. Je staat op om de deur te openen. Maar voordat je dat doet, zet je een bladwijzer op de laatste pagina die je gelezen hebt. Je slaat mentaal de gebeurtenissen van het plot op. Dan, ga je je pizza halen. Eenmaal terug in uw kamer, begint u het boek vanaf de bladzijde waar u de bladwijzer heeft gelegd. Je begint niet meer vanaf de eerste bladzijde. In zekere zin heb je gehandeld als een generator functie.
Laten we eens kijken hoe we generatoren kunnen gebruiken om een aantal veel voorkomende problemen tijdens het programmeren op te lossen. Maar laten we eerst definiëren wat generatoren zijn.
Wat zijn generatoren?
Een normale functie zoals deze kan niet worden gestopt voordat zijn taak is voltooid, d.w.z. de laatste regel is uitgevoerd. Het volgt iets dat run-to-completion model wordt genoemd.
function normalFunc() {
console.log('I')
console.log('cannot')
console.log('be')
console.log('stopped.')
}
De enige manier om de normalFunc
te verlaten is door return
er uit te gaan, of throw
een foutmelding te geven. Als je de functie opnieuw aanroept, begint hij de uitvoering weer van voren af aan.
In tegenstelling daarmee is een generator een functie die halverwege kan stoppen en dan verder kan gaan vanaf het punt waar hij gestopt is.
Hier zijn enkele andere veelgebruikte definities van generatoren –
- Generatoren zijn een speciale klasse van functies die de taak van het schrijven van iteratoren vereenvoudigen.
- Een generator is een functie die een reeks resultaten produceert in plaats van een enkele waarde, d.w.z. je genereert een reeks waarden.
In JavaScript is een generator een functie die een object teruggeeft waarop je next()
kunt aanroepen. Elke aanroep van next()
zal een object teruggeven met de vorm –
{
value: Any,
done: true|false
}
De value
eigenschap zal de waarde bevatten. De done
eigenschap is ofwel true
of false
. Wanneer de done
true
wordt, stopt de generator en zal geen waarden meer genereren.
Hier volgt een illustratie van hetzelfde –
Noteer de gestippelde pijl die de yield-resume-yield-lus sluit vlak voor Finish in het Generators-gedeelte van de afbeelding. Er is een mogelijkheid dat een generator nooit eindigt. We zullen later een voorbeeld zien.
Een generator maken
Laten we eens kijken hoe we een generator kunnen maken 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
Focus op de vetgedrukte delen. Voor het maken van een generator functie, gebruiken we function *
syntaxis in plaats van alleen function
. Tussen het function
sleutelwoord, de *
, en de functienaam kan elk aantal spaties staan. Aangezien het slechts een functie is, kunt u deze overal gebruiken waar een functie kan worden gebruikt, d.w.z. binnen objecten en methoden van klassen.
Binnen de body van de functie hebben we geen return
. In plaats daarvan hebben we een ander sleutelwoord yield
(Lijn 2). Het is een operator waarmee een generator zichzelf kan pauzeren. Elke keer dat een generator een yield
tegenkomt, “retourneert” hij de waarde die erachter is gespecificeerd. In dit geval wordt Hello,
geretourneerd. We zeggen echter niet “teruggegeven” in de context van generators. We zeggen “de generator heeft Hello,
opgeleverd”.
We kunnen ook terugkeren van een generator. Echter, return
zet de done
eigenschap op true
waarna de generator geen waarden meer kan genereren.
function * generatorFunc() {
yield 'a';
return 'b'; // Generator ends here.
yield 'a'; // Will never be executed.
}
In regel 3, maken we het generator object. Het lijkt alsof we de functie generatorFunction
aanroepen. Dat is ook zo! Het verschil is dat een generator functie altijd een generator object teruggeeft, in plaats van een waarde terug te geven. Het generator object is een iterator. U kunt het dus gebruiken in for-of
-lussen of andere functies die een iterable accepteren.
In regel 4 roepen we de next()
-methode op de generatorObject
aan. Met deze oproep, begint de generator met uitvoeren. Eerst console.log
de This will be executed first.
Dan komt hij een yield 'Hello, '
tegen. De generator geeft de waarde als een object { value: 'Hello, ', done: false }
en onderbreekt/pauzeert. Nu wacht hij op de volgende aanroep.
In regel 5 roepen we next()
weer op. Deze keer wordt de generator wakker en begint met uitvoeren vanaf het punt waar hij gebleven was. De volgende regel die hij vindt is een console.log
. Het logt de string I will be printed after the pause
. Een andere yield
wordt gevonden. De waarde wordt weergegeven als het object { value: 'World!', done: false }
. We extraheren de value
eigenschap en loggen het. De generator slaapt weer.
In regel 6 roepen we opnieuw next()
op. Deze keer zijn er geen regels meer om uit te voeren. Onthoud dat elke functie impliciet undefined
retourneert als er geen return statement is gegeven. Vandaar dat de generator een object { value: undefined, done: true}
teruggeeft (in plaats van het te retourneren). De done
wordt gezet op true
. Dit betekent het einde van deze generator. Nu kan hij geen waarden meer genereren of opnieuw beginnen omdat er geen statements meer zijn om uit te voeren.
We moeten een nieuw generator object maken om de generator opnieuw te starten.
Toepassingen van Generators
Er zijn vele geweldige gebruiksscenario’s van generators. Laten we er eens een paar bekijken.
Implementeren van Iterables
Wanneer u een iterator implementeert, moet u handmatig een iterator-object maken met een next()
methode. Ook moet je handmatig de toestand opslaan. Vaak wordt het erg moeilijk om dat te doen. Aangezien generatoren ook iterables zijn, kunnen ze gebruikt worden om iterables te implementeren zonder de extra boilerplate code. Laten we eens kijken naar een eenvoudig voorbeeld.
Probleem: We willen een aangepaste iterable maken die This
, is
, en iterable.
retourneert. Hier is een implementatie met behulp van iterators –
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.
Hier is hetzelfde met behulp van generators –
function * iterableObj() {
yield 'This';
yield 'is';
yield 'iterable.'
}for (const val of iterableObj()) {
console.log(val);
}// This
// is
// iterable.
Je kunt beide versies vergelijken. Het is waar dat dit een beetje een gekunsteld voorbeeld is. Maar het illustreert wel de punten –
- We hoeven ons geen zorgen te maken over
Symbol.iterator
- We hoeven
next()
. - We hoeven het return object van
next()
niet handmatig te maken, d.w.z.{ value: 'This', done: false }
. - We hoeven de state niet op te slaan. In het voorbeeld van de iterator, werd de toestand opgeslagen in de variabele
step
. De waarde daarvan bepaalde wat er werd uitgevoerd door de iterable. We hoefden niets van dit soort te doen in de generator.
Betere Async functionaliteit
Code die beloften en callbacks gebruikt zoals –
function fetchJson(url) {
return fetch(url)
.then(request => request.text())
.then(text => {
return JSON.parse(text);
})
.catch(error => {
console.log(`ERROR: ${error.stack}`);
});
}
kan worden geschreven als (met de hulp van bibliotheken zoals 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}`);
}
});
Sommige lezers hebben misschien opgemerkt dat het parallel loopt met het gebruik van async/await
. Dat is geen toeval. async/await
kan een soortgelijke strategie volgen en vervangt de yield door await
in gevallen waar het om beloften gaat. Het kan gebaseerd zijn op generatoren. Zie dit commentaar voor meer info.
Infinite Data Streams
Het is mogelijk om generatoren te maken die nooit eindigen. Beschouw dit voorbeeld –
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
We maken een generator naturalNumbers
. Binnen de functie, hebben we een oneindige while
lus. In die lus yield
we de num
. Als de generator opbrengt, wordt hij opgeschort. Wanneer we next()
opnieuw oproepen, wordt de generator wakker, gaat verder waar hij was gestopt (in dit geval yield num
) en voert uit tot een andere yield
wordt gevonden of tot de generator eindigt. Aangezien het volgende statement num = num + 1
is, werkt hij num
bij. Dan gaat hij naar de top van de while-lus. De voorwaarde is nog steeds waar. De volgende regel is yield num
. Het geeft de bijgewerkte num
en stopt. Dit gaat zo lang door als je wilt.
Generators als waarnemers
Generators kunnen ook waarden ontvangen met behulp van de next(val)
functie. Dan wordt de generator een waarnemer genoemd, omdat hij wakker wordt als hij nieuwe waarden ontvangt. In zekere zin blijft hij observeren voor waarden en handelt als hij er een krijgt. Je kunt hier meer over dit patroon lezen.
Voordelen van Generators
Zoals te zien is in het voorbeeld van Infinite Data Streams, is het alleen mogelijk door lazy evaluation. Luie evaluatie is een evaluatiemodel dat de evaluatie van een expressie uitstelt totdat de waarde nodig is. Dat wil zeggen, als we de waarde niet nodig hebben, bestaat hij niet. Hij wordt berekend als we hem nodig hebben. Laten we een voorbeeld bekijken –
function * powerSeries(number, power) {
let base = number;
while(true) {
yield Math.pow(base, power);
base++;
}
}
De powerSeries
geeft de reeks van het getal verheven tot een macht. Bijvoorbeeld, een machtsreeks van 3 verheven tot 2 zou zijn 9(3²) 16(4²) 25(5²) 36(6²) 49(7²). Wanneer we const powersOf2 = powerSeries(3, 2);
doen, maken we alleen het generator-object. Geen van de waarden is berekend. Als we nu next()
oproepen, wordt 9 berekend en teruggezonden.
Memory Efficient
Een direct gevolg van Lazy Evaluation is dat generatoren memory efficient zijn. We genereren alleen de waarden die nodig zijn. Met normale functies moeten we alle waarden vooraf genereren en ze bewaren voor het geval we ze later gebruiken. Maar met generatoren kunnen we de berekening uitstellen tot we ze nodig hebben.
We kunnen combinator-functies maken om op generatoren in te werken. Combinators zijn functies die bestaande iterables combineren om nieuwe te maken.Een zo’n combinator is take
. Het neemt de eerste n
elementen van een iterable. Hier is een implementatie –
function * take(n, iter) {
let index = 0;
for (const val of iter) {
if (index >= n) {
return;
}
index = index + 1;
yield val;
}
}
Hier zijn enkele interessante use cases van take
–
take(3, )// a b ctake(7, naturalNumbers());// 1 2 3 4 5 6 7take(5, powerSeries(3, 2));// 9 16 25 36 49
Hier is een implementatie van cycled library (zonder de omkeer-functionaliteit).
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
Caveats
Er zijn enkele punten die je moet onthouden tijdens het programmeren met behulp van generatoren.
- Generator-objecten zijn slechts eenmalig toegankelijk. Als je alle waarden hebt uitgeput, kun je er niet opnieuw overheen lopen. Als u de waarden opnieuw wilt genereren, moet u een nieuw generatorobject maken.
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
- Generatorobjecten staan geen willekeurige toegang toe, zoals mogelijk is met arrays. Aangezien de waarden één voor één worden gegenereerd, zou de toegang tot een willekeurige waarde leiden tot de berekening van waarden tot aan dat element. Het is dus geen willekeurige toegang.
Conclusie
Er moet nog veel worden geregeld in generatoren. Dingen zoals yield *
, return()
en throw()
. Generatoren maken ook coroutines mogelijk. Ik heb een aantal referenties opgesomd die je kunt lezen om meer inzicht te krijgen in generatoren.
Je kunt naar Python’s itertools pagina gaan, en enkele van de hulpprogramma’s bekijken die het werken met iteratoren en generatoren mogelijk maken. Als oefening kun je de hulpprogramma’s zelf implementeren.