Czym są generatory w JavaScript?
Generatory to specyficzne funkcje, które mogą być wstrzymywane i wznawiane w dowolnym momencie. Zwykłe funkcje wykonują całą logikę wewnątrz po inwokacji. Oprócz tego mogą zwrócić tylko jedną wartość po wykonaniu, natomiast generatory mogą zwrócić kilka wartości niezależnie.
Przykład generatora:
Wywołanie funkcji generatora nie spowoduje wykonania kodu, który znajduje się wewnątrz. W momencie wywołania funkcji, która jest generatorem, zwracany jest obiekt o równie strasznie brzmiącej nazwie - ITERATOR (jak jakiś imperator czy coś…). Iterator jak sama nazwa wskazuje to obiekt, po którym będzie można iterować poprzez wywołanie na nim metody next()
. Dopiero wywołanie metody next()
na iteratorze spowoduje wykonanie się pierwszej części kodu w funkcji generatora, aż do natrafienia na pierwsze magiczne słowo yield
. yield
z angielskiego, w formie czasownika oznacza nic innego jak “dawać, dać, przynieść.” W praktyce znaczy to tyle, że jeśli podczas wywołania metody next()
na iteratorze, interpreter języka trafił na słowo yield
to teraz funkcja powinna nam coś dać, zwrócić. Dla dużego uproszczenia można stwierdzić, że yield
to taki return
ale na sterydach. A co zwróci nam ta funkcja po natrafieniu na yield
? Zwróci obiekt w postaci:
{value: '', done: false}
.
Fajnie, prawda?
Składnia generatorów
Myślę, że nieco łatwiej będzie zrozumieć generatory na prostym przykładzie. Spójrzmy na kod poniżej.
Funkcja foo
jest generatorem. Wywołanie funkcji foo()
nie wykona kodu, który znajduje się w środku tylko zwróci nam obiekt iteratora. Jako że chcemy mieć referencje do instancji tego iteratora (bo chcemy!), aby móc się do niej odwoływać, to zapisujemy ją sobie do stałej.
W kolejnych krokach, wywołując na zapisanym obiekcie iteratora metodę next()
w konsoli otrzymamy:
Zwróć uwagę na klucz done
. W ostatnim trzecim wywołaniu jest on ustawiony na true
. Oznacza to, że iteracja po obiekcie iteratora dobiegła końca a wywołanie kolejnych metod next()
już nic nowego nam nie zwróci.
Proste i klarowne.
Obiekt iteratora
Obiekt iteratora, który powstaje w momencie inwokacji generatora, posiada trzy metody, które nas interesują. next()
, return()
oraz throw()
.
next()
- wywołanie tej metody pozwala na otrzymanie wyniku wyrażenia które nastepuje po słowieyield
,return()
- wywołanie tej metody spowoduje zakończenie działania iteratora. Zwrócony zostanie obiekt{value: undefined, done: true}
throw()
- wywołanie tej metody zwróci błąd na iteratorze, dodatkowo możemy podać treść błędu jako argument np.throw('Treść mojego błędu.')
Słowo kluczowe yield
Magiczne słowo kluczowe yield
jest używane w funkcjach zwanych generatorami do zatrzymania egzekucji kodu i zwrócenia wartości wyrażenia, które znajduje się po słowie kluczowym yield
. Tym wyrażeniem może być cokolwiek. To może byś string, liczba, asynchroniczny request do API lub wywołanie funkcji, która zwraca jakąś wartość. Dla lepszego zrozumienia zerknij, proszę na poniższe przykłady.
Pięciokrotne wywołanie metody next()
na iteratorze funkcji, która jest generatorem (skąd wiemy, że jest? - bo ma tę gwiazdkę przy słowie function) spowoduje zwrócenie poniższych wartości.
Zwróć uwagę funkcję implementacje funkcji foo
a szczególnie na linijkę
yield fetch('http://some-api-url.com')
Wywołałem tam metodę fetch
, która wykonuje asynchroniczny request do API. Wyniki tego requestu zostaną zwrócone KIEDYŚ w przyszłości (na tym polega asynchroniczność, ale o tym jeszcze kiedyś napiszę). Pytanie brzmi, czy w generatorze po słowie kluczowym yield
można wywołać metodę asynchroniczną, która zwraca Promise
? Odpowiedź w następnym akapicie.
Generatory vs. async/await
Zarówno generatory, jak i funkcje asynchroniczne mogą zostać użyte do wykonania logiki, która w naszej aplikacji będzie wywoływała jakieś side-effects. Może to być reuqest do api, może to być wywołanie funkcji setTimeout
która odwleka “coś” w czasie, może to być odwołanie się do zmiennej, która znajduje się poza obecnym kontekstem itd.
Odpowiedź na pytanie, czy mogę w generatorze wywołać metodę, która zwraca Promise brzmi: TAK.
W tym momencie pojawia się w głowie pytanie: czy generatory są alternatywą dla funkcji asynchronicznych async/await
?. Generatory są alternatywnym rozwiązaniem, które może zostać użyte do egzekucji metod które zwracają Promise, ale ich rolą nie jest całkowite zastąpienie składni async/await
.
Do podobieństw funkcji asynchronicznych ze składnią async/await
i generatorów na pewno należy fakt, że w momencie natrafienia na słowo kluczowe await
czy yield
funkcja czeka na wykonanie wyrażenia, które po nich następuje. Oba te zapisy sprawiają, że kod asynchroniczny wygląda na synchroniczny — jest to łatwiejsze do zrozumienia dla człowieka, który czyta ten kod.
Różnicą jest natomiast fakt, że jeśli funkcja async
zawiera w sobie kilka deklaracji await
to awaity te są wykonywane sekwencyjnie jeden po drugim. Jak tylko jeden się zakończy, to rozpoczyna się kolejny.
W generatorach natomiast do momentu wywołania po raz kolejny metody next()
na obiekcie iteratora, nic się nie wykona. Oznacza to, że mamy większą kontrolę nad tym, co kiedy zostaje wykonane.
Warto też dodać, że po await
rezultatem zawsze będzie Promise
natomiast w przypadku yield
rezulatem jest obiekt:
{value: <value>;, done: <boolean>};
.
Oczywiście po słowie yield
możemy wywołać metodę, która zwraca Promise
, wtedy wynikiem wywołania iteratora będzie obiekt zawierający Promise
:
{value: Promise, done: <boolean>}
.
Aby odczytać wartość tego Promise'a
, należałoby go obsłużyć dodatkowym helperem. Aby zbytnio nie komplikować, nie będę tutaj podawał przykładowej implementacji takiego helpera - dla ciekawskich odsyłam do tego linku na Stackoverflow.
Przykład użycia generatora
Przykładem użycia takiego generatora może być jedno z ulubionych zadań programistów-rekruterów zadawanych na rozmowach rekrutacyjnych. Ciąg Fibonacciego ;)
Oprócz takiego użycia, generatory mogą posłużyć np. do generowania unikalnych identyfikatorów(id), do throttle’ingu innych funkcji, do egzekucji funkcji w kolejności liniowej gdzie jedna zależy od poprzedniej.
Zaletą generatorów jest niewątpliwie ich wydajność z punktu widzenia alokacji pamięci. Wartości przechowywane w pamięci to tylko te, które są potrzebne w danym momencie. W przypadku normalnych funkcji wszystkie wartości są zwracane i alokowane w “szufladce” pamięci i muszą być tam przechowywane na wypadek, gdyby były potrzebne w przyszłości. W przypadku generatora, jeśli nie potrzebujemy kolejnej wartości, nie wywołujemy metody next()
przez co niepotrzebnie nie alokujemy pamięci komputera. Pamięć jest przydzielana tylko na nasze żądanie. Jeśli jakaś wartość nie jest potrzebna, to po prostu nie będzie istnieć.
Podsumowanie
Stworzenie synchronicznej maszyny stanu, własna implementacja throthle’ingu, generowanie unikalnych ID na żądanie to jedne z wielu możliwych use-case’ów gdzie generatory mogą być świetnym rozwiązaniem. Oczywiście nie powinno się po nie sięgać nagminnie i bez przemyślenia. Jeśli coś może być zrobione w sposób prostszy, np. zwykłą regularną funkcją to nie ma sensu zastępować return
-a, yield
-em tylko po to, żeby być fancy ;) Warto natomiast znać i rozumieć generatory gdyż w ekosystemie JavaScript’u jest kilka popularnych bibliotek, które z tego konceptu korzystają, np. redux-saga
.
Pozdrawiam serdecznie!