ES6 introducerede en ny måde at arbejde med funktioner og iteratorer på i form af generatorer (eller generatorfunktioner). En generator er en funktion, der kan stoppe midtvejs og derefter fortsætte derfra, hvor den stoppede. Kort sagt ser en generator ud til at være en funktion, men den opfører sig som en iterator.
Fun Fact: async/await
kan være baseret på generatorer. Læs mere her.
Generatorer er indviklet forbundet med iteratorer. Hvis du ikke kender til iteratorer, er her en artikel til at forbedre din forståelse af dem.
Her er en simpel analogi for at få en intuition for generatorer, før vi går videre med de tekniske detaljer.
Forestil dig, at du læser en neglebidende technothriller. Du er helt opslugt af bogens sider, og du hører knap nok din dørklokke ringe. Det er pizzabuddet. Du rejser dig for at åbne døren. Men inden du gør det, sætter du et bogmærke på den sidste side, du har læst. Du gemmer mentalt begivenhederne i handlingen. Derefter går du hen og henter din pizza. Når du vender tilbage til dit værelse, begynder du bogen fra den side, som du satte bogmærket på. Du begynder ikke på den fra den første side igen. På en måde har du fungeret som en generatorfunktion.
Lad os se, hvordan vi kan udnytte generatorer til at løse nogle almindelige problemer under programmering. Men før det, skal vi først definere, hvad generatorer er.
Hvad er generatorer?
En normal funktion som denne kan ikke stoppes, før den er færdig med sin opgave, dvs. dens sidste linje er udført. Den følger noget, der hedder run-to-completion-modellen.
function normalFunc() {
console.log('I')
console.log('cannot')
console.log('be')
console.log('stopped.')
}
Den eneste måde at afslutte normalFunc
på er ved at return
gå fra den eller throw
give en fejl. Hvis du kalder funktionen igen, vil den begynde udførelsen fra begyndelsen igen.
I modsætning hertil er en generator en funktion, der kan stoppe midtvejs og derefter fortsætte derfra, hvor den stoppede.
Her er nogle andre almindelige definitioner af generatorer –
- Generatorer er en særlig klasse af funktioner, der forenkler opgaven med at skrive iteratorer.
- En generator er en funktion, der producerer en sekvens af resultater i stedet for en enkelt værdi, dvs. du genererer en serie af værdier.
I JavaScript er en generator en funktion, der returnerer et objekt, som du kan kalde next()
på. Hvert kald af next()
returnerer et objekt af formen –
{
value: Any,
done: true|false
}
Egenskaben value
vil indeholde værdien. done
-egenskaben er enten true
eller false
. Når done
bliver til true
, stopper generatoren og vil ikke generere flere værdier.
Her er en illustration af det samme –
Bemærk den stiplede pil, der lukker yield-resume-yield loopet lige før Finish i Generatorer-delen af billedet. Der er en mulighed for, at en generator aldrig kan afsluttes. Vi vil se et eksempel senere.
Skabelse af en generator
Lad os se, hvordan vi kan oprette en generator i 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
Fokuser på de fede dele. Til at oprette en generatorfunktion bruger vi function *
syntaks i stedet for blot function
. Der kan være et vilkårligt antal mellemrum mellem nøgleordet function
, *
og funktionsnavnet. Da det blot er en funktion, kan du bruge den overalt, hvor en funktion kan bruges, dvs. inde i objekter og klassemetoder.
Inde i funktionskroppen har vi ikke et return
. I stedet har vi et andet nøgleord yield
(linje 2). Det er en operator, hvormed en generator kan sætte sig selv på pause. Hver gang en generator støder på et yield
, “returnerer” den den værdi, der er angivet efter det. I dette tilfælde returneres Hello,
. Vi siger dog ikke “returneret” i forbindelse med generatorer. Vi siger “generatoren har givet Hello,
“.
Vi kan også returnere fra en generator. Men return
indstiller done
-egenskaben til true
, hvorefter generatoren ikke kan generere flere værdier.
function * generatorFunc() {
yield 'a';
return 'b'; // Generator ends here.
yield 'a'; // Will never be executed.
}
I linje 3 opretter vi generatorobjektet. Det ser ud til, at vi påkalder funktionen generatorFunction
. Det gør vi faktisk! Forskellen er, at i stedet for at returnere en værdi returnerer en generatorfunktion altid et generatorobjekt. Generatorobjektet er en iterator. Så du kan bruge det i for-of
sløjfer eller andre funktioner, der accepterer en iterabel.
I linje 4 kalder vi next()
-metoden på generatorObject
. Med dette kald begynder generatoren at blive eksekveret. Først console.log
den This will be executed first.
Derefter støder den på en yield 'Hello, '
. Generatoren afgiver værdien som et objekt { value: 'Hello, ', done: false }
og suspenderer/pauser. Nu venter den på det næste kald.
I linje 5 kalder vi next()
igen. Denne gang vågner generatoren op og begynder at udføre fra det sted, hvor den slap. Den næste linje, den finder, er en console.log
. Den logger strengen I will be printed after the pause
. Der støder på endnu en yield
. Værdien afgives som objektet { value: 'World!', done: false }
. Vi uddrager value
-egenskaben og logger den. Generatoren sover igen.
I linje 6 påkalder vi igen next()
. Denne gang er der ikke flere linjer, der skal udføres. Husk, at enhver funktion implicit returnerer undefined
, hvis der ikke er angivet nogen return statement. Derfor returnerer generatoren (i stedet for at afgive) et objekt { value: undefined, done: true}
. done
er sat til true
. Dette signalerer afslutningen af denne generator. Nu kan den ikke generere flere værdier eller genoptage igen, da der ikke er flere instruktioner, der skal udføres.
Vi skal lave et nyt andet generatorobjekt for at køre generatoren igen.
Anvendelser af generatorer
Der er mange fantastiske anvendelsesmuligheder for generatorer. Lad os se nogle få af dem.
Implementering af Iterables
Når du implementerer en iterator, skal du manuelt lave et iteratorobjekt med en next()
-metode. Du skal også manuelt gemme tilstanden manuelt. Ofte bliver det rigtig svært at gøre det. Da generatorer også er iterables, kan de bruges til at implementere iterables uden den ekstra boilerplate-kode. Lad os se et simpelt eksempel.
Problem: Vi ønsker at lave en brugerdefineret iterabel, der returnerer This
, is
og iterable.
. Her er en implementering ved hjælp af iteratorer –
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.
Her er den samme ting ved hjælp af generatorer –
function * iterableObj() {
yield 'This';
yield 'is';
yield 'iterable.'
}for (const val of iterableObj()) {
console.log(val);
}// This
// is
// iterable.
Du kan sammenligne begge versioner. Det er rigtigt, at dette er et noget konstrueret eksempel. Men det illustrerer pointerne –
- Vi behøver ikke at bekymre os om
Symbol.iterator
- Vi behøver ikke at implementere
next()
. - Vi behøver ikke manuelt at lave returobjektet for
next()
dvs.{ value: 'This', done: false }
. - Vi behøver ikke at gemme tilstanden. I iteratorens eksempel blev tilstanden gemt i variablen
step
. Dens værdi definerede, hvad der blev output fra iterablen. Vi behøvede ikke at gøre noget af den slags i generatoren.
Bedre asynkron funktionalitet
Kode, der bruger løfter og callbacks som –
function fetchJson(url) {
return fetch(url)
.then(request => request.text())
.then(text => {
return JSON.parse(text);
})
.catch(error => {
console.log(`ERROR: ${error.stack}`);
});
}
kan skrives som (ved hjælp af biblioteker som 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}`);
}
});
Som nogle læsere har måske bemærket, at der er en parallel til brugen af async/await
. Det er ikke en tilfældighed. async/await
kan følge en lignende strategi og erstatter yield med await
i tilfælde, hvor der er tale om promises. Den kan være baseret på generatorer. Se denne kommentar for mere info.
Infinite Data Streams
Det er muligt at lave generatorer, der aldrig slutter. Overvej dette eksempel –
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
Vi laver en generator naturalNumbers
. Inde i funktionen har vi en uendelig while
sløjfe. I denne løkke yield
vi num
den num
. Når generatoren giver efter, bliver den suspenderet. Når vi kalder next()
igen, vågner generatoren op, fortsætter fra det sted, hvor den blev suspenderet (i dette tilfælde yield num
) og udfører, indtil vi støder på en anden yield
, eller generatoren slutter. Da den næste erklæring er num = num + 1
, opdaterer den num
. Derefter går den til toppen af while loop. Betingelsen er stadig sand. Den støder på den næste linje yield num
. Den giver den opdaterede num
og suspenderer. Dette fortsætter så længe du ønsker det.
Generatorer som observatører
Generatorer kan også modtage værdier ved hjælp af funktionen next(val)
. Så kaldes generatoren for en observatør, da den vågner op, når den modtager nye værdier. I en vis forstand bliver den ved med at observere efter værdier og handler, når den får en. Du kan læse mere om dette mønster her.
Fordelene ved generatorer
Som det ses i eksemplet Infinite Data Streams, er det kun muligt på grund af lazy evaluation. Lazy Evaluation er en evalueringsmodel, der forsinker evalueringen af et udtryk, indtil der er brug for dets værdi. Det vil sige, at hvis vi ikke har brug for værdien, vil den ikke eksistere. Den beregnes, efterhånden som vi kræver den. Lad os se et eksempel –
function * powerSeries(number, power) {
let base = number;
while(true) {
yield Math.pow(base, power);
base++;
}
}
Den powerSeries
giver rækken af det tal, der er hævet til en potens. F.eks. ville potensserien af 3 hævet til 2 være 9(3²) 16(4²) 25(5²) 36(6²) 49(7²). Når vi laver const powersOf2 = powerSeries(3, 2);
, opretter vi blot generatorobjektet. Ingen af værdierne er blevet beregnet. Hvis vi nu kalder next()
, ville 9 blive beregnet og retuneret.
Memory Efficient
En direkte konsekvens af Lazy Evaluation er, at generatorer er memory efficient. Vi genererer kun de værdier, der er nødvendige. Med normale funktioner var vi nødt til at generere alle værdierne på forhånd og gemme dem, hvis vi skulle bruge dem senere. Med generatorer kan vi imidlertid udskyde beregningen, indtil vi har brug for den.
Vi kan oprette kombinatorfunktioner til at handle på generatorer. Combinators er funktioner, der kombinerer eksisterende iterables for at skabe nye. en sådan combinator er take
. Den tager de første n
elementer af en iterabel. Her er en implementering –
function * take(n, iter) {
let index = 0;
for (const val of iter) {
if (index >= n) {
return;
}
index = index + 1;
yield val;
}
}
Her er nogle interessante anvendelsestilfælde af take
–
take(3, )// a b ctake(7, naturalNumbers());// 1 2 3 4 5 6 7take(5, powerSeries(3, 2));// 9 16 25 36 49
Her er en implementering af cyklet bibliotek (uden reverseringsfunktionaliteten).
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
Der er nogle punkter, som du bør huske, når du programmerer ved hjælp af generatorer.
- Generatorobjekter er kun engangsadgang. Når du har opbrugt alle værdierne, kan du ikke iterere over det igen. Hvis du vil generere værdierne igen, skal du lave et nyt generatorobjekt.
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
- Generatorobjekter tillader ikke tilfældig adgang, som det er muligt med arrays. Da værdierne genereres én efter én, ville adgang til en tilfældig værdi føre til beregning af værdierne indtil det pågældende element. Derfor er der ikke tale om tilfældig adgang.
Slutning
Der er mange ting, der endnu ikke er dækket i generatorer. Ting som yield *
, return()
og throw()
. Generatorer gør også coroutines mulige. Jeg har listet nogle referencer, som du kan læse for at få yderligere forståelse for generatorer.
Du kan gå over til Pythons itertools-side, og se nogle af de hjælpeprogrammer, der gør det muligt at arbejde med iteratorer og generatorer. Som en øvelse kan du selv implementere hjælpeprogrammerne.