Az ES6 bevezette a függvényekkel és iterátorokkal való munka új módját a generátorok (vagy generátorfüggvények) formájában. A generátor egy olyan függvény, amely félúton megállhat, majd onnan folytathatja, ahol megállt. Röviden, egy generátor függvénynek tűnik, de úgy viselkedik, mint egy iterátor.
Fun Fact: async/await
lehet generátorokra építeni. Bővebben itt olvashatsz.
A generátorok szorosan kapcsolódnak az iterátorokhoz. Ha nem ismered az iterátorokat, itt egy cikk, hogy jobban megértsd őket.
Itt egy egyszerű analógia, hogy legyen egy megérzésed a generátorokról, mielőtt a technikai részletekkel folytatnánk.
Képzeld el, hogy egy körömrágós techno-thrillert olvasol. Teljesen belemerülve a könyv lapjaiba, alig hallja meg a csengőt. A pizzafutár az. Felállsz, hogy kinyisd az ajtót. Mielőtt azonban ezt megtenné, könyvjelzőt tesz az utolsó olvasott oldalra. Mentálisan elmented a cselekmény eseményeit. Aztán elmész a pizzáért. Miután visszatértél a szobádba, azon az oldalon kezded el a könyvet, ahol a könyvjelzőt elhelyezted. Nem kezded újra az első oldalról. Bizonyos értelemben generátorfüggvényként működtél.
Lássuk, hogyan használhatjuk fel a generátorokat néhány gyakori probléma megoldására programozás közben. De előtte határozzuk meg, hogy mik azok a generátorok.
Mi a generátor?
Egy ilyen normál függvényt nem lehet leállítani, mielőtt befejezné a feladatát, azaz végrehajtaná az utolsó sorát. Követi az úgynevezett run-to-completion modellt.
function normalFunc() {
console.log('I')
console.log('cannot')
console.log('be')
console.log('stopped.')
}
Az egyetlen módja a normalFunc
kilépésnek, ha return
kilépünk belőle, vagy ha throw
hibát jelezünk. Ha újra meghívjuk a függvényt, akkor az újra elölről kezdi a végrehajtást.
A generátor ezzel szemben olyan függvény, amely félúton megállhat, majd onnan folytathatja, ahol megállt.
Itt van még néhány általános definíció a generátorokról –
- A generátorok a függvények egy speciális osztálya, amely leegyszerűsíti az iterátorok írását.
- A generátor olyan függvény, amely egyetlen érték helyett eredmények sorozatát állítja elő, azaz értékek sorozatát generálja.
A JavaScriptben a generátor olyan függvény, amely egy olyan objektumot ad vissza, amelyen next()
hívható. A next()
minden meghívása egy –
{
value: Any,
done: true|false
}
alakú objektumot ad vissza, amelynek value
tulajdonsága tartalmazza az értéket. A done
tulajdonság vagy true
vagy false
. Amikor a done
true
lesz, a generátor leáll, és nem fog több értéket generálni.
Itt egy illusztráció ugyanerről –
A kép Generators részében a befejezés előtt közvetlenül a yield-resume-yield loopot lezáró szaggatott nyilat figyeljük. Lehetséges, hogy egy generátor soha nem ér véget. Erre később látunk majd példát.
Generátor létrehozása
Lássuk, hogyan hozhatunk létre generátort JavaScriptben –
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
Fókuszáljunk a félkövér betűs részekre. A generátorfüggvény létrehozásához a function *
szintaxist használjuk az egyszerű function
helyett. A function
kulcsszó, a *
és a függvénynév között tetszőleges számú szóköz lehet. Mivel ez csak egy függvény, bárhol használhatjuk, ahol egy függvényt használhatunk, azaz objektumokon és osztályok metódusain belül.
A függvénytestben nincs return
. Helyette van egy másik yield
kulcsszavunk (2. sor). Ez egy olyan operátor, amellyel a generátor szüneteltetheti önmagát. Minden alkalommal, amikor a generátor találkozik egy yield
-vel, “visszaadja” az utána megadott értéket. Ebben az esetben Hello,
kerül visszaadásra. A generátorok kontextusában azonban nem azt mondjuk, hogy “visszaadta”. Azt mondjuk, hogy “a generátor Hello,
-t adott ki”.
A generátorból is visszatérhetünk. Azonban return
a done
tulajdonságot true
-ra állítja, ami után a generátor nem tud több értéket generálni.
function * generatorFunc() {
yield 'a';
return 'b'; // Generator ends here.
yield 'a'; // Will never be executed.
}
A 3. sorban létrehozzuk a generátor objektumot. Úgy tűnik, mintha a generatorFunction
függvényt hívnánk meg. Valóban így van! A különbség az, hogy a generátorfüggvény ahelyett, hogy bármilyen értéket adna vissza, mindig egy generátorobjektumot ad vissza. A generátor objektum egy iterátor. Így használhatjuk for-of
ciklusokban vagy más, iterálhatót elfogadó függvényekben.
A 4. sorban meghívjuk a generatorObject
next()
metódust. Ezzel a hívással a generátor elkezdi a végrehajtást. Először console.log
a This will be executed first.
majd találkozik egy yield 'Hello, '
-val. A generátor az értéket egy { value: 'Hello, ', done: false }
objektumként adja ki, és felfüggeszti/szünetet tart. Most a következő meghívásra vár.
Az 5. sorban ismét meghívjuk a next()
. Ezúttal a generátor felébred, és onnan kezdi a végrehajtást, ahol abbahagyta. A következő sor, amit talál, egy console.log
. Naplózza a I will be printed after the pause
karakterláncot. Újabb yield
sorral találkozik. Az értéket a { value: 'World!', done: false }
objektumként adja ki. Kivonjuk a value
tulajdonságot és naplózzuk. A generátor ismét alszik.
A 6. sorban ismét meghívjuk a next()
-t. Ezúttal nincs több végrehajtandó sor. Ne feledjük, hogy minden függvény implicit módon visszatér undefined
, ha nincs return utasítás. Ezért a generátor (ahelyett, hogy visszaadná) egy { value: undefined, done: true}
objektumot ad vissza. A done
értéke true
lesz. Ez jelzi a generátor végét. Most már nem tud több értéket generálni vagy újra folytatni, mivel nincs több végrehajtandó utasítás.
Új generátor objektumot kell készítenünk, hogy a generátort újra futtathassuk.
A generátorok felhasználása
A generátoroknak sok fantasztikus felhasználási esete van. Lássunk néhányat közülük.
Iterátorok implementálása
Az iterátor implementálásakor kézzel kell létrehoznunk egy next()
metódussal rendelkező iterátor objektumot. Emellett manuálisan el kell mentened az állapotot. Gyakran előfordul, hogy ez nagyon nehézzé válik. Mivel a generátorok is iterátorok, ezért az iterátorok implementálására is használhatók az extra boilerplate kód nélkül. Lássunk egy egyszerű példát:
Probléma: Egy olyan egyéni iterálhatót szeretnénk létrehozni, amely This
, is
és iterable.
értékeket ad vissza. Itt van egy megvalósítás iterátorok használatával –
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.
Itt van ugyanez generátorok használatával –
function * iterableObj() {
yield 'This';
yield 'is';
yield 'iterable.'
}for (const val of iterableObj()) {
console.log(val);
}// This
// is
// iterable.
Mindkét verziót összehasonlíthatjuk. Igaz, hogy ez egy kissé mesterkélt példa. De jól szemlélteti a lényeget –
- Nekünk nem kell aggódnunk a
Symbol.iterator
- Nekünk nem kell implementálnunk a
next()
. - Nekünk nem kell manuálisan elkészítenünk a
next()
visszatérő objektumát, azaz a{ value: 'This', done: false }
. - Nekünk nem kell elmentenünk az állapotot. Az iterátor példájában az állapotot a
step
változóba mentettük. Ennek értéke határozta meg, hogy mit adjon ki az iterábilis. A generátorban semmi ilyesmit nem kellett tennünk.
Jobb aszinkron funkcionalitás
Az ígéreteket és callbackeket használó kód, mint például –
function fetchJson(url) {
return fetch(url)
.then(request => request.text())
.then(text => {
return JSON.parse(text);
})
.catch(error => {
console.log(`ERROR: ${error.stack}`);
});
}
megírható (olyan könyvtárak segítségével, mint a 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}`);
}
});
Egyik olvasó talán észrevette, hogy ez párhuzamos a async/await
használatával. Ez nem véletlen egybeesés. A async/await
hasonló stratégiát követhet, és a yield-t await
-val helyettesíti azokban az esetekben, amikor ígéretekről van szó. Ez generátorokon alapulhat. További információért lásd ezt a megjegyzést.
Infinite Data Streams
El lehet készíteni olyan generátorokat, amelyek soha nem érnek véget. Tekintsük ezt a példát –
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
Elkészítünk egy naturalNumbers
generátort. A függvényen belül van egy végtelen while
ciklusunk. Ebben a ciklusban yield
a num
-et yield
. Amikor a generátor megadja magát, felfüggesztjük. Amikor újra meghívjuk a next()
-t, a generátor felébred, onnan folytatja, ahol felfüggesztettük (ebben az esetben yield num
), és addig hajtja végre, amíg egy újabb yield
nem érkezik, vagy a generátor be nem fejezi. Mivel a következő utasítás num = num + 1
, frissíti a num
-et. Ezután a while ciklus elejére lép. A feltétel még mindig igaz. A következő yield num
sorral találkozik. Kiadja a frissített num
-et és felfüggeszti. Ezt addig folytatja, ameddig akarja.
A generátorok mint megfigyelők
A generátorok a next(val)
függvény segítségével is kaphatnak értékeket. Ekkor a generátort megfigyelőnek nevezzük, mivel akkor ébred fel, amikor új értékeket kap. Bizonyos értelemben folyamatosan figyeli az értékeket, és akkor cselekszik, amikor kap egyet. Erről a mintáról itt olvashat bővebben.
A generátorok előnyei
A végtelen adatfolyam példájánál látható, hogy ez csak a lusta kiértékelés miatt lehetséges. A Lazy Evaluation egy olyan kiértékelési modell, amely egy kifejezés kiértékelését addig késlelteti, amíg annak értékére szükség van. Vagyis ha nincs szükségünk az értékre, akkor az nem is fog létezni. Akkor kerül kiszámításra, amikor igényt tartunk rá. Lássunk egy példát –
function * powerSeries(number, power) {
let base = number;
while(true) {
yield Math.pow(base, power);
base++;
}
}
A powerSeries
a hatványra emelt szám sorozatát adja. Például a 3-nak a 2-re emelt hatványsorozata a következő lenne: 9(3²) 16(4²) 25(5²) 36(6²) 49(7²). Amikor a const powersOf2 = powerSeries(3, 2);
-t csináljuk, csak létrehozzuk a generátor objektumot. Egyik értéket sem számoltuk ki. Ha most a next()
-t hívnánk, a 9-et kiszámítanánk és visszahangolnánk.
Memóriahatékony
A Lazy Evaluation közvetlen következménye, hogy a generátorok memóriahatékonyak. Csak azokat az értékeket generáljuk, amelyekre szükségünk van. A normál függvényeknél az összes értéket előre kellett generálnunk, és megtartani őket arra az esetre, ha később használnánk őket. A generátorokkal azonban elhalaszthatjuk a számítást addig, amíg szükségünk van rá.
A generátorokkal kombinátorfüggvényeket hozhatunk létre. A kombinátorok olyan függvények, amelyek meglévő iterábilisokat kombinálnak, hogy újakat hozzanak létre. az egyik ilyen kombinátor a take
. Egy iterábilis első n
elemét veszi. Itt van egy implementáció –
function * take(n, iter) {
let index = 0;
for (const val of iter) {
if (index >= n) {
return;
}
index = index + 1;
yield val;
}
}
Itt van a take
néhány érdekes felhasználási esete –
take(3, )// a b ctake(7, naturalNumbers());// 1 2 3 4 5 6 7take(5, powerSeries(3, 2));// 9 16 25 36 49
Itt van egy ciklikus könyvtár implementációja (a visszafordító funkció nélkül).
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
Van néhány pont, amit a generátorok használatával történő programozás során érdemes megjegyezni.
- A generátorobjektumok csak egyszeri hozzáféréssel rendelkeznek. Ha egyszer már kimerítetted az összes értéket, nem tudsz rajta újra iterálni. Az értékek újbóli generálásához egy új generátorobjektumot kell létrehozni.
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
- A generátorobjektumok nem teszik lehetővé a véletlenszerű hozzáférést, mint ahogy az a tömbökkel lehetséges. Mivel az értékek egyenként generálódnak, egy véletlen érték elérése az adott elemig az értékek kiszámításához vezetne. Ezért ez nem véletlenszerű hozzáférés.
Következtetés
A generátorokban még sok mindenre kell kitérni. Olyan dolgok, mint a yield *
, return()
és throw()
. A generátorok lehetővé teszik a coroutine-okat is. Felsoroltam néhány hivatkozást, amit elolvashatsz, hogy jobban megértsd a generátorokat.
Elmehetsz a Python itertools oldalára, és megnézhetsz néhány segédprogramot, amelyek lehetővé teszik az iterátorokkal és generátorokkal való munkát. Gyakorlatként te magad is implementálhatod a segédprogramokat.