ES6 bez tajemnic to cykl artykułów poświęconych nowym składnikom języka JavaScript, które pojawiły się w 6. edycji standardu ECMAScript, w skrócie ES6.
Witaj w kolejnej odsłonie serii artykułów ES6 bez tajemnic!
Pisałem kiedyś o generatorach – nowym rodzaju funkcji wprowadzonym w ES6. Nazwałem je najbardziej magicznym elementem funkcjonalności w nowym standardzie i opisałem, jak mogą wpłynąć na programowanie asynchroniczne w przyszłości. Dodałem także, że:
To nie wszystko jeśli chodzi o generatory… Sądzę jednak, że ten artykuł jest już wystarczająco długi i zdążył porządnie namieszać ci w głowie. Tak jak generatory, powinniśmy się na razie wstrzymać i wrócić do tego tematu za jakiś czas.
Ten czas nadszedł właśnie teraz.
Pierwsza część niniejszego artykułu znajduje się tutaj. Dobrze byłoby, gdybyś zapoznał się z nią przed przystąpieniem do dalszej lektury tego tekstu. Śmiało, będziesz się dobrze bawić. Artykuł jest wprawdzie… trochę długi i może namieszać ci w głowie. Ale zobaczysz gadającego kota!
Szybka powtórka z rozrywki
W poprzednim artykule skupiliśmy się na podstawowym sposobie działania generatorów. Może się on wydawać nieco dziwny, lecz nie jest trudno go zrozumieć. Generatory w dużej mierze przypominają zwykłe funkcje. Główna różnica polega na tym, że w przypadku generatorów funkcja nie jest uruchamiana od razu w całości. Wykonanie następuje krok po kroku, z zatrzymaniem funkcji przy każdej instrukcji yield
.
Szczegółowy opis generatorów znajduje się w części pierwszej niniejszego artykułu, jednak nie przestudiowaliśmy tam konkretnego przykładu ilustrującego współdziałanie wszystkich części generatora. Zrobimy to zatem teraz.
function* someWords() {
yield "witaj";
yield "świecie";
}
for (var word of someWords()) {
alert(word);
}
Powyższy kod jest wystarczająco klarowny. Jeśli jednak chcielibyśmy zobaczyć wszystkie kryjące się za nim operacje, wszystkie role odgrywane przez poszczególne fragmenty kodu, to potrzebowalibyśmy nieco innego tekstu. Oto przykład:
SCENA – KOMPUTER WEWNĘTRZNY, DZIEŃ PĘTLA FOR stoi samotnie na scenie, z kaskiem na głowie i notatnikiem w ręku, pełen profesjonalizm. PĘTLA FOR (wywołuje) someWords()! Pojawia się GENERATOR: wysoki blaszany dżentelmen o mechanicznych ruchach. Wygląda całkiem przyjaźnie, lecz staje nieruchomo niczym posąg. PĘTLA FOR (żwawo klaszcze w dłonie) No dobra! Bierzmy się do pracy. (do generatora) .next()! GENERATOR { value: "witaj", done: false } Zastyga w głupawej pozie. PĘTLA FOR alert! Biegiem pojawia się ALERT, z trudem łapie oddech. Jego oczy są wybałuszone. Wydaje się, że zawsze jest w takim stanie. PĘTLA FOR Przywitaj się z użytkownikiem. ALERT obraca się i ucieka czym prędzej ze sceny. ALERT (za kulisami, krzyczy) Zatrzymać wszystko! Strona shebang.pl mówi: "witaj"! Po kilku sekundach ALERT wbiega ponownie i kieruje się prosto w stronę PĘTLI FOR. Z trudem wyhamowuje. ALERT Użytkownik mówi OK. PĘTLA FOR (żwawo klaszcze w dłonie) No dobra! Bierzmy się do pracy. (odwraca się do GENERATORA) .next()! GENERATOR ponownie ożywa. GENERATOR {value: "świecie", done: false} Zastyga w innej głupawej pozie. PĘTLA FOR alert! ALERT (w biegu) Się robi! (za kulisami, krzyczy) Zatrzymać wszystko! Strona shebang.pl mówi: "świecie"! Jeszcze jedna pauza. ALERT ciężkim krokiem wraca na scenę, nagle wydaje się strapiony. ALERT Użytkownik znów mówi OK, ale... proszę, niech ta strona nie generuje już więcej okien dialogowych. Wychodzi nadąsany. PĘTLA FOR (żwawo klaszcze w dłonie) No dobra! Bierzmy się do pracy. (odwraca się do GENERATORA) .next()! GENERATOR ożywa po raz trzeci. GENERATOR (z godnością) {value: undefined, done: true} Jego głowa opada na pierś, z oczu znika blask. Nigdy się już nie poruszy. PĘTLA FOR Czas na lunch. Wychodzi. Po chwili na scenie pojawia się SPRZĄTACZ, podnosi martwy GENERATOR i zanosi go za kulisy.
No dobra, nie jest to do końca Hamlet, ale wiesz o co chodzi.
Jak widać w powyższej sztuce, obiekt generatora jest wstrzymany, gdy pojawia się po raz pierwszy. Budzi się zaś do chwilowego działania po każdym wywołaniu metody .next()
.
Akcja jest synchroniczna i jednowątkowa. Zauważ, że w danym czasie tylko jedna postać coś robi. Postaci nigdy nie wchodzą sobie nawzajem w zdanie ani się nie przekrzykują. Mówią jeden po drugim, a ten, do kogo w danej chwili należy głos może kontynuować tak długo, jak tylko chce. (Tak jak u Szekspira!)
Tego rodzaju sztuka odgrywana jest za każdym razem, gdy generator zostaje przekazany pętli for–of
. Zawsze istnieje przedstawiona sekwencja wywołań metody .next()
, które nie są widoczne nigdzie w kodzie. Tutaj umieściłem je wszystkie na scenie, lecz w przypadku programów odgrywają one swoją rolę za kulisami – generatory i pętla for–of
zostały bowiem zaprojektowane tak, by funkcjonować razem, za pośrednictwem interfejsu iteratora.
Podsumowując:
- obiekty generatora to uprzejme blaszane roboty zwracające wartości;
- każdy robot zaprogramowany jest za pomocą jednego bloku kodu: treści głównej funkcji generatora.
Wyłączanie generatora
Generatory mają kilka fikuśnych, dodatkowych elementów funkcjonalności, których nie omówiłem w części pierwszej:
generator.return()
- opcjonalny argument metody
generator.next()
generator.throw(error)
yield*
Pominąłem je głównie z tego względu, że jeśli nie wiemy po co istnieją, to nie będą nas obchodzić, a już na pewno nie będziemy mieć ich stale na uwadze. Kiedy jednak zastanowimy się głębiej nad tym jak programy mogą korzystać z generatorów, wszystko stanie się jasne.
Oto schemat, z którego już pewnie zdarzyło ci się korzystać:
function doThings() {
setup();
try {
// ... zrób coś ...
} finally {
cleanup();
}
}
doThings();
Operacja cleanup()
(sprzątanie) może oznaczać zamknięcie połączeń lub plików, uwolnienie zasobów systemowych, czy po prostu aktualizację struktury DOM w celu wyłączenia „bączka” oznaczającego operacje w toku. Chcemy, by czynności te zostały wykonane niezależnie od tego czy nasza praca zakończy się pomyślnie, dlatego umieszczamy je w bloku finally
.
Jak wyglądałoby to w przypadku generatora?
function* produceValues() {
setup();
try {
// ... zwróć jakieś wartości ...
} finally {
cleanup();
}
}
for (var value of produceValues()) {
work(value);
}
Wygląda w porządku. Jest tu jednak drobna kwestia warta uwagi: wywołanie work(value)
nie znajduje się w bloku try
. Co się stanie z etapem sprzątania jeśli zostanie zgłoszony wyjątek?
Albo załóżmy, że pętla for–of
zawiera instrukcję break
lub return
. Co się wówczas stanie ze zwalnianiem zasobów?
Zostanie wykonane i tak. ES6 cię ubezpiecza.
Kiedy po raz pierwszy omawialiśmy temat iteratorów i pętli for–of, powiedzieliśmy, że interfejs iteratora zawiera opcjonalną metodę .return()
, która wywoływana jest automatycznie, jeśli iteracja dobiegnie końca nim iterator przekaże informację o jej zakończeniu. Metoda ta obsługiwana jest przez generatory. Wywołanie metody myGenerator.return()
spowoduje uruchomienie przez generator bloków finally
i zakończenie pracy, tak jakby obecna instrukcja yield
została magicznie zamieniona na polecenie return
.
Warto zwrócić uwagę, że metoda .return()
nie jest automatycznie wywoływana we wszystkich kontekstach, lecz jedynie w tych przypadkach, w których wykorzystywany jest protokół iteracji. Generator może zatem zostać usunięty przez sprzątacz nawet jeśli nie wykona bloku finally
.
Jak można by to odegrać na scenie? Generator zostaje wstrzymany w środku zadania, które, podobnie jak budowa wieżowca, wymaga przygotowania. Nagle ktoś wtrąca wyjątek! Zostaje on przechwycony przez pętlę for
, która następnie odkłada go na bok. Nakazuje generatorowi wykonanie metody .return()
. Generator grzecznie sprząta całe rusztowanie i się wyłącza. Następnie pętla for
podnosi błąd i ma miejsce dalsza normalna obsługa wyjątków.
Władza należy do generatorów
Do tej pory rozmowa generatora i użytkownika była raczej jednostronna. Oderwijmy się na chwilę od teatralnych porównań:
Jak widać, władza należy do użytkownika. Generator wykonuje polecenia na żądanie. To jednak nie jedyny sposób na użycie generatorów.
W pierwszej części powiedziałem, że generatory mają zastosowanie w programowaniu asynchronicznym. Generatory można wykorzystać na przykład do obsługiwania asynchronicznych wywołań zwrotnych czy łączenia w łańcuch obietnic. Zastanawiasz się pewnie, jak miałoby to dokładnie wyglądać. Dlaczego wystarczy jedynie zwracanie wartości poprzez instrukcję yield
(co jest tak naprawdę jedyną szczególną cechą generatorów)? W końcu kod asynchroniczny nie tylko zwraca wartości, lecz sprawia, że coś się dzieje. Pobiera dane z plików i baz danych oraz wysyła żądania do serwerów, a następnie wraca do pętli zdarzeń i czeka aż te asynchroniczne procesy się zakończą. Jak dokładnie wykonałyby to generatory? I jak, bez korzystania z wywołań zwrotnych, poradziłyby sobie z przyjmowaniem danych z plików, baz danych i serwerów?
By uzyskać odpowiedź na te pytania zastanówmy się, co by było, gdyby kod wywołujący metodę .next()
mógł przekazywać wartości z powrotem do generatora. Dzięki tej jednej zmianie rozmowa przybiera zupełnie inną postać.
Ponadto metoda .next()
przyjmuje tak naprawdę argument opcjonalny – sztuczka polega na tym, że argument jest interpretowany przez generator jako wartość zwrócona przez wyrażenie yield
. Wyrażenie yield
nie jest instrukcją taką jak return
– ma ono wartość po wznowieniu pracy generatora.
var results = yield getDataAndLatte(request.areaCode);
Jak na jedną linijkę kodu zawartych jest tu wiele operacji:
- Wywołana zostaje metoda
getDataAndLatte()
. Powiedzmy, że funkcja zwraca łańcuch"get me the database records for area code..."
, który widzieliśmy na poprzednim zrzucie ekranu. - Generator zostaje wstrzymany i poprzez wyrażenie
yield
zostaje zwrócona wartość łańcucha. - Upływający czas nie ma już teraz znaczenia.
- W końcu ktoś wywoła metodę
.next({data: ..., coffee: ...})
. Zapiszemy przekazany metodzie obiekt w zmiennej lokalnejresults
i przejdziemy do kolejnej linijki kodu.
Spójrzmy, jak całość prezentuje się w kontekście. Oto kod zawierający całą opisaną powyżej rozmowę:
function* handle(request) {
var results = yield getDataAndLatte(request.areaCode);
results.coffee.drink();
var target = mostUrgentRecord(results.data);
yield updateStatus(target.id, "ready");
}
Zauważ, że yield
wciąż funkcjonuje tak samo, jak do tej pory: wstrzymuje generator i przekazuje wartość z powrotem do funkcji wywołującej. Ale ileż się tu pozmieniało! Teraz generator oczekuje od kodu wywołującego konkretnego działania – liczy, że kod ten będzie się zachowywał jak jego asystent.
Zwykłe funkcje zazwyczaj takie nie są. Z reguły istnieją po to, by spełniać potrzeby wywołującego je kodu. Z generatorami można jednak wdać się w rozmowę, co zwiększa zakres możliwych relacji między generatorem a kodem go wywołującym.
Jak mógłby zatem wyglądać asystent generatora? Wcale nie musi być szczególnie skomplikowany. Oto przykład:
function runGeneratorOnce(g, result) {
var status = g.next(result);
if (status.done) {
return; // uff!
}
// Generator poprosił, byśmy coś pobrali
// i wywołali go z powrotem kiedy już skończymy.
doAsynchronousWorkIncludingEspressoMachineOperations(
status.value,
(error, nextResult) => runGeneratorOnce(g, nextResult));
}
By rozpocząć, musielibyśmy utworzyć generator i raz go uruchomić, w ten sposób:
runGeneratorOnce(handle(request), undefined);
Wspomniałem kiedyś, że Q.async()
to przykład biblioteki, która traktuje generatory jak procesy asynchroniczne i wywołuje je automatycznie wtedy, gdy zajdzie taka potrzeba. Metoda runGeneratorOnce
to coś podobnego. W praktyce generator nie zwraca łańcuchów określających co kod wywołujący ma zrobić. Najprawdopodobniej zwrócone zostaną obietnice.
Jeśli zrozumiałeś już funkcjonowanie obietnic, a teraz rozumiesz także generatory, możesz spróbować zmodyfikować metodę runGeneratorOnce
, tak by obsługiwała obietnice. To trudne, lecz kiedy już uda ci się ta sztuka, będziesz mógł pisać złożone asynchroniczne algorytmy z użyciem obietnic w przejrzysty sposób, bez metody .then()
czy wywołania zwrotnego na horyzoncie.
Jak wysadzić generator
Zauważyłeś jak metoda runGeneratorOnce
obsługuje błędy? Ignoruje je!
Nie jest to dobra wiadomość. Zależałoby nam, by zgłoszenie błędu do generatora było możliwe. Generatory umożliwiają rozwiązanie tego problemu: zamiast metody generator.next(result)
można wywołać generator.throw(error)
. Spowoduje to, że wyrażenie yield
zgłosi błąd. Podobnie jak metoda .return()
, generator zostanie najprawdopodobniej zakończony, ale jeśli obecna instrukcja yield
znajduje się w bloku try
, to brane pod uwagę będą bloki catch
i finally
– generator może zatem zostać przywrócony.
Zmodyfikowanie runGeneratorOnce
tak, by metoda .throw()
była wywoływana poprawnie to kolejne wyzwanie. Należy pamiętać, że wyjątki zgłaszane wewnątrz generatorów są zawsze przekazywane kodowi wywołującemu, a więc metoda generator.throw(error)
zwróci nam błąd, o ile nie zostanie on wcześniej przechwycony przez generator.
Oto uzupełnienie możliwych sytuacji po wstrzymaniu generatora przy wyrażeniu yield
:
- Ktoś może wywołać metodę
generator.next(value)
. W takim przypadku generator wznawia swoją pracę dokładnie od tego miejsca, w którym został wstrzymany. - Ktoś może wywołać
generator.return()
, opcjonalnie przekazując wartość. W tym wypadku generator nie wznawia pracy – wykonuje jedynie blokifinally
. - Ktoś może wywołać metodę
generator.throw(error)
. Generator zachowuje się wówczas tak samo, jak gdyby wyrażenieyield
było wywołaniem funkcji, która zgłosiła błąd. - Może się też nie zdarzyć żadna z wymienionych rzeczy, a generator zostanie wstrzymany na zawsze. (Tak, możliwe jest, że generator przejdzie do bloku
try
bez wykonywania blokufinally
. Znajdujący się w takim stanie generator może nawet zostać przechwycony przez sprzątacz).
Nie jest to wszystko o wiele bardziej skomplikowane niż zwykłe wywołanie funkcji. Jedyną nowością jest tak naprawdę możliwość wywołania metody .return()
.
W rzeczywistości wyrażenie yield
ma wiele wspólnego z wywołaniami funkcji. Kiedy wywołujemy funkcję JavaScript to zostajemy tymczasowo wstrzymani, prawda? Kontrolę przejmuje wywołana funkcja. Może zwrócić jakąś wartość. Może zgłosić jakiś wyjątek. Równie dobrze może też zapętlać się w nieskończoność.
Generatory, które ze sobą współpracują
Pozwól, że zaprezentuję jeszcze jedną rzecz. Załóżmy, że chcemy napisać prosty generator, by połączyć dwa iterowalne obiekty:
function* concat(iter1, iter2) {
for (var value of iter1) {
yield value;
}
for (var value of iter2) {
yield value;
}
}
W ES6 kod ten można zapisać w skróconej wersji:
function* concat(iter1, iter2) {
yield* iter1;
yield* iter2;
}
Zwykłe wyrażenie yield
zwraca pojedynczą wartość, natomiast wyrażenie yield*
przetwarza cały iterator i zwraca wszystkie wartości.
Tą samą składnią można rozwiązać jeszcze jeden dziwny problem – wywoływania generatora z innego generatora. W przypadku zwykłych funkcji możemy wyciągnąć fragment kodu z jednej funkcji i zrefaktoryzować go do postaci innej funkcji, nie zmieniając sposobu jej działania. Naturalnie będziemy chcieli refaktoryzywać także i generatory. Musimy jednak znaleźć sposób na wywołanie takiego zrefaktoryzowanego podprogramu i upewnić się, że każda wartość zwracana wcześniej będzie zwracana w dalszym ciągu, choć tym razem przez podprogram. Rozwiązaniem jest instrukcja yield*
.
function* factoredOutChunkOfCode() { ... }
function* refactoredFunction() {
...
yield* factoredOutChunkOfCode();
...
}
Wyobraź sobie, że jeden blaszany robot deleguje podzadania swojemu koledze. Jak widzisz, to bardzo istotne przy pisaniu dużych projektów bazujących na generatorach, jak również dbanie o przejrzystość kodu i jego dobrą organizację – podobnie ważne są funkcje w organizowaniu kodu synchronicznego.
Ukłony
To wszystko jeśli chodzi o generatory! Mam nadzieję, że bawiłeś się równie dobrze jak ja.
W kolejnym artykule omówimy jeszcze jeden dezorientujący składnik ES6 – zupełną nowość. Obiekty tak niepozorne, tak fikuśne, że można korzystać z nich zupełnie nieświadomie. Odwiedzaj nas zatem często, by dowiedzieć się, jak funkcjonują obiekty pośredniczące w ES6.