ES6 bez tajemnic. Generatory – ciąg dalszy

12 stycznia 2016
1 gwiadka2 gwiazdki3 gwiazdki4 gwiazdki5 gwiazdek

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 lokalnej results 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 bloki finally.
  • Ktoś może wywołać metodę generator.throw(error) . Generator zachowuje się wówczas tak samo, jak gdyby wyrażenie yield 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 bloku finally. 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.

Autor: Jason Orendorff

Źródło: https://hacks.mozilla.org/2015/07/es6-in-depth-generators-continued/

Tłumaczenie: Joanna Liana

Treść tej strony dostępna jest na zasadach licencji CC BY-SA 3.0

Zobacz również:

Dyskusja

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *