Posty

Czym są i do czego służą generatory w JavaScript?

JavaScript Generatory

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:

Foo

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.

Foo

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:

Output1

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

Output1

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łowie yield,
  • 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.

Bar

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.

Output2

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 ;)

Fibonacci

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!