ES6 bez tajemnic. Generatory

06 października 2015
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ą się w 6. edycji standardu ECMAScript, w skrócie ES6.

Temat dzisiejszego artykułu to coś, co mnie niezmiernie fascynuje. Powiemy sobie bowiem o najbardziej magicznym elemencie funkcjonalności w ES6.

Dlaczego „magicznym”? Po pierwsze różni się on od innych składników JS do tego stopnia, że na pierwszy rzut oka przypomina jakieś tajemne zaklęcie. Można właściwie powiedzieć, że stawia JS na głowie! Jeśli to nie czary, to nie wiem jak to inaczej określić.

Ponadto, dzięki niemu możemy znacznie uprościć kod oraz zapomnieć o koszmarze wywołań zwrotnych, co sprawia, że mamy do czynienia wręcz ze zjawiskami nadprzyrodzonymi.

Chyba już zaczynam przesadzać, prawda? Przejdźmy więc do sedna, a przekonasz się o tym wszystkim sam.

Generatory w ES6 – wprowadzenie

Czym są generatory? Na początek spójrzmy na przykład:

function* quips(name) {
  yield "witaj " + name + "!";
  yield "Mam nadzieję, że podobają ci się moje artykuły";
  if (name.startsWith("X")) {
    yield "Ale fajnie, że twoje imię zaczyna się na X, " + name;
  }
  yield "Do zobaczenia!";
}

Oto kod gadającego kota, prawdopodobnie najważniejszego obecnie rodzaju aplikacji internetowej. (Śmiało, kliknij i pobaw się z kotem. Gdy stwierdzisz, że masz już w głowie kompletny mętlik, wróć do naszego artykułu po wyjaśnienie).

Przypomina funkcję, prawda? To specjalny rodzaj funkcji zwany generatorem. Choć generatory mają wiele wspólnego z funkcjami, od razu można zauważyć istniejące pomiędzy nimi dwie różnice:

  • Zwykłe funkcje deklarowane są za pomocą słowa kluczowego function, natomiast generatory za pomocą wyrażenia function*.
  • Wewnątrz generatora yield jest słowem kluczowym o składni podobnej do instrukcji return. Różnica polega na tym, że instrukcja return może być użyta wewnątrz funkcji (nawet generatora) tylko raz. W przypadku instrukcji yield takich ograniczeń nie ma. Wyrażenie yield powoduje wstrzymanie wykonywania generatora, co umożliwia późniejsze wznowienie jego działania.

I to by było na tyle. Oto kluczowa różnica pomiędzy zwykłymi funkcjami a generatorami: wstrzymanie funkcji nie jest możliwe. Generatory natomiast wstrzymać można.

Jak działają generatory

Co się stanie, jeśli wywołamy funkcję quips()?


> var iter = quips("jorendorff");
  [object Generator]
> iter.next()
  { value: "Witaj jorendorff!", done: false }
> iter.next()
  { value: "Mam nadzieję, że podobają ci się moje artykuły", done: false }
> iter.next()
  { value: "Do zobaczenia!", done: false }
> iter.next()
  { value: undefined, done: true }

Przyzwyczaiłeś się już pewnie do działania zwykłych funkcji. Standardowa funkcja uruchamia się zaraz po jej wywołaniu, a jej działanie dobiega końca w momencie wykonania instrukcji return bądź zgłoszenia wyjątku. Dla programistów JS to zupełnie oczywiste.

Samo wywołanie generatora wygląda identycznie: quips("jorendorff"). Generator nie rozpoczyna jednak działania od razu. Na początek zwracany jest wstrzymany obiekt generatora (w powyższym przykładzie nosi on nazwę iter) – można go porównać do wstrzymanego wywołania funkcji. Do wstrzymania tego dochodzi zaraz na początku generatora, tuż przed wykonaniem pierwszej linijki kodu.

Po każdym wywołaniu metody obiektu generatora .next() funkcja zostaje wznowiona i jest wykonywana aż do kolejnej instrukcji yield.

Z tego powodu za każdym razem po wywołaniu metody iter.next() otrzymywaliśmy inną wartość łańcuchową. Wartości zwracane są przez instrukcję yield znajdującą się w głównej treści funkcji quips().

Po ostatnim wywołaniu metody iter.next() generator dobiega końca, dlatego w polu .done zwracanego obiektu mamy wartość true. Zakończenie wywoływania funkcji oznacza praktycznie zwrócenie wartości undefined, stąd też wartość ta pojawia się w polu .value.

Teraz nadszedł odpowiedni moment, by powrócić do gadającego kota i porządnie poeksperymentować z kodem. Wstaw na próbę do pętli instrukcję yield. Co się stanie?

Za każdym razem, gdy generator wykonuje instrukcję yield jego ramka stosu – zmienne lokalne, argumenty, wartości tymczasowe oraz informacja na temat aktualnego miejsca wykonania w kodzie generatora – zostaje usunięta ze stosu. Obiekt generatora przechowuje jednak odniesienie do ramki (bądź jego kopię), dzięki czemu kolejne wywołanie metody .next() może ją reaktywować i kontynuować wykonywanie funkcji.

Warto w tym miejscu zauważyć, że generatory nie są wątkami. W językach z wątkami równocześnie można wykonywać kilka bloków kodu, co zwykle powoduje wystąpienie zjawiska wyścigu (ang. race condition) oraz przekłada się na nieliniowe wykonanie kodu i lepszą wydajność. Z generatorami jest zupełnie inaczej. Generator wykonywany jest w tym samym wątku co kod inicjujący wywołanie. Porządek wykonywania funkcji jest sekwencyjny i deterministyczny, lecz nigdy współbieżny. W przeciwieństwie do wątków systemowych generator może być zawieszony jedynie w miejscach oznaczonych w głównej treści funkcji wyrażeniem yield.

W porządku. Wiemy już, czym są generatory. Zobaczyliśmy, jak generator zostaje uruchomiony, wstrzymany, a następnie wznowiony. A teraz czas na kluczowe pytanie: do czego ta dziwaczna cecha generatorów mogłaby się przydać?

Generatory są iteratorami

Jak powiedzieliśmy w poprzednim artykule, iteratory w ES6 nie stanowią pojedynczej wbudowanej klasy, tylko są punktem rozszerzenia języka. Utworzenie własnych iteratorów możliwe jest poprzez zaimplementowanie dwóch metod: [Symbol.iterator]() i .next().

Implementacja interfejsu zawsze jednak oznacza trochę pracy. Zobaczmy więc na przykładzie jak wygląda implementowanie iteratora w praktyce. Utworzymy prosty iterator o nazwie range odliczający od jednej liczby do drugiej, tak jak klasyczna pętla for (;;) z języka C.

// kod powinien dać sygnał trzy razy
for (var value of range(0, 3)) {
  alert("Ding! Piętro nr" + value);
}

Oto przykładowe rozwiązanie wykorzystujące klasę z ES6. (Jeśli składnia klas jest dla ciebie niejasna, nie martw się – powiemy o niej w jednej z kolejnych artykułów).

class RangeIterator {
  constructor(start, stop) {
    this.value = start;
    this.stop = stop;
  }

  [Symbol.iterator]() { return this; }

  next() {
    var value = this.value;
    if (value < this.stop) {
      this.value++;
      return {done: false, value: value};
    } else {
      return {done: true, value: undefined};
    }
  }
}

// zwróć nowy iterator liczący od liczby 'start' do 'stop'.
function range(start, stop) {
  return new RangeIterator(start, stop);
}

Zobacz ten kod w akcji:

See the Pen JYoKVY by Łukasz Piwko (@shebangpl) on CodePen.

Tak więc wygląda implementacja iteratorów w Javie czy Swifcie. Nie najgorzej, ale banalne to nie jest. Czy kod zawiera jakieś błędy? Trudno powiedzieć. Powyższy kod, mający naśladować oryginalną pętlę for (;;), zupełnie nie przypomina swojego pierwowzoru: protokół iteratora zmusza nas do zakończenia pętli.

Teraz pewnie twój entuzjazm wobec iteratorów trochę opadł. Można je świetnie wykorzystać, ale ich implementacja wydaje się trudna.

Pewnie nawet przez myśl ci nie przeszło, że moglibyśmy wprowadzić do JS dziwaczną i trudną do zrozumienia strukturę sterowania przepływem tylko po to, by ułatwić tworzenie iteratorów. Ale skoro mamy już do dyspozycji generatory, może dałoby się je tutaj wykorzystać? Sprawdźmy:

function* range(start, stop) {
  for (var i = start; i < stop; i++)
    yield i;
}

Zobacz powyższy kod w akcji:

See the Pen zvxBQO by Łukasz Piwko (@shebangpl) on CodePen.

Powyższym 4-linijkowym generatorem można zastąpić poprzednie 23 linijki funkcji range(), łącznie z całą klasą RangeIterator. Taka zamiana jest możliwa, ponieważ generatory są iteratorami. Wszystkie generatory mają wbudowaną implementację metod .next() i [Symbol.iterator]() . Programista musi tylko napisać kod realizujący działanie pętli.

Implementowanie iteratorów bez generatorów można porównać do sytuacji, w której pisząc e-mail dozwolone byłoby jedynie korzystanie z konstrukcji w stronie biernej. Kiedy nie możemy w prostych słowach wyrazić tego, co mamy na myśli, tworzymy wypowiedzi, które są dosyć zawiłe. Klasa RangeIterator jest długa i dziwna, ponieważ musi opisać funkcjonowanie pętli bez korzystania ze składni pętli. Rozwiązaniem są generatory.

Jakie jeszcze inne zastosowania mogą mieć generatory pełniące funkcje iteratorów?

  • Przekształcanie dowolnego obiektu na obiekt iterowalny. W tym celu wystarczy napisać generator, który przegląda obiekt this i na bieżąco zwraca odczytaną wartość, a następnie zainstalować go jako metodę [Symbol.iterator] danego obiektu.
  • Upraszczanie funkcji budujących tablice. Załóżmy, że mamy funkcję zwracającą tablicę z wynikami po każdym wywołaniu, np.:
    // Dzieli jednowymiarową tablicę icons
    // na tablice o długości równej rowLength.
    function splitIntoRows(icons, rowLength) {
      var rows = [];
      for (var i = 0; i < icons.length; i += rowLength) {
        rows.push(icons.slice(i, i + rowLength));
      }
      return rows;
    }
    

    Dzięki generatorom taki kod można nieco skrócić:

    function* splitIntoRows(icons, rowLength) {
      for (var i = 0; i < icons.length; i += rowLength) {
        yield icons.slice(i, i + rowLength);
      }
    }
    

    Jedyna różnica w zachowaniu generatora polega na tym, że zamiast obliczać wszystkie wyniki na raz i zwracać je w formie tablicy, kod zwraca iterator, a każdy wynik obliczany jest pojedynczo w razie potrzeby.

  • Wyniki niestandardowych rozmiarów. Nie da się zbudować nieskończenie długiej tablicy. Można natomiast zwrócić generator, który wygeneruje nieskończoną sekwencję, a każdy kod inicjujący wywołanie będzie mógł czerpać z niej tyle wartości, ile będzie wymagane.
  • Refaktoryzacja złożonych pętli. Twój kod zawiera długą, paskudną funkcję? Chciałbyś rozbić ją na dwie mniej skomplikowane części? Generatory to nowe narzędzie, które możesz od dziś wykorzystywać do refaktoryzacji kodu. W przypadku skomplikowanych pętli możliwe jest wyselekcjonowanie części kodu wytwarzającej dane i przekształcenie jej na osoby generator. Następnie należy przedefiniować pętlę do postaci for (var data of myNewGenerator(args)).
  • Narzędzia do pracy z obiektami iterowalnymi. ES6 nie zawiera rozbudowanej biblioteki narzędzi do filtrowania, mapowania i, ogólnie rzecz ujmując, hakowania iterowalnych zbiorów danych. Generatory jednak świetnie się nadają do tworzenia potrzebnych narzędzi, i to za pomocą zaledwie kilku linijek kodu.

    Załóżmy przykładowo, że potrzebujemy ekwiwalentu metody Array.prototype.filter, który będzie operował nie tylko na tablicach, lecz także na obiektach NodeList ze struktury DOM. Bułka z masłem:

    function* filter(test, iterable) {
      for (var item of iterable) {
        if (test(item))
          yield item;
      }
    }
    

Czy można zatem powiedzieć, że generatory są przydatne? Bez wątpienia. Można za ich pomocą w zdumiewająco łatwy sposób zaimplementować własne iteratory, które stanowią nowy standard danych i pętli w ES6.

Nie jest to jednak wszystko, co potrafią generatory. Być może nie jest to nawet ich najważniejsza właściwość.

Generatory i kod asynchroniczny

Oto fragment kodu, który napisałem jakiś czas temu:


          };
        })
      });
    });
  });
});

Może widziałeś już coś podobnego we własnym kodzie. Asynchroniczne interfejsy API zwykle wymagają wywołania zwrotnego, co za każdym razem, gdy chcemy coś wykonać, pociąga za sobą konieczność napisana dodatkowej funkcji anonimowej. Jeśli zatem weźmiemy kod, który nie jest złożony z trzech linijek, lecz wykonuje trzy różne operacje, widzimy wówczas trzy poziomy wcięcia kodu.

Oto jeszcze jeden fragment napisanego przeze mnie kodu JS:

}).on('close', function () {
  done(undefined, undefined);
}).on('error', function (error) {
  done(error);
});

W asynchronicznych interfejsach API zamiast wyjątków wykorzystuje się konwencjonalne techniki obsługi błędów. Różnią się one między sobą zależnie od danego API. W większości interfejsów błędy są po cichu ignorowane. W niektórych przypadkach domyślnie ignorowane jest nawet prawidłowe wykonanie kodu.

Do tej pory tego typu problemy były ceną za możliwość korzystania z kodu asynchronicznego. Zdążyliśmy się już pogodzić z tym, że kod asynchroniczny nie jest tak prosty i przyjemny dla oka co kod synchroniczny.

Generatory dają jednak nadzieję, że wcale nie musi tak być.

Funkcja Q.async() to eksperymentalna próba wykorzystania generatorów w połączeniu z obietnicami (ang. promises) w celu napisania kodu asynchronicznego, który będzie odwzorowywał odpowiadający mu kod synchroniczny. Przykład:

// kod synchroniczny, który trochę pohałasuje
function makeNoise() {
  shake();
  rattle();
  roll();
}

// kod asynchroniczny, który trochę pohałasuje
// zwraca obiekt obietnicy, która zostaje spełniona
// kiedy skończymy już hałasować
function makeNoise_async() {
  return Q.async(function* () {
    yield shake_async();
    yield rattle_async();
    yield roll_async();
  });
}

Główna różnica w porównaniu z rozwiązaniem synchronicznym polega na tym, że do kodu w wersji asynchronicznej należy dodać słowo kluczowe yield w każdym miejscu, w którym wywoływana jest funkcja asynchroniczna.

Dodanie teoretycznie „nieproszonej” instrukcji if czy bloku try-catch do wersji Q.async działa tak samo, jak dodanie ich do zwykłego kodu synchronicznego. W porównaniu z innymi sposobami, korzystając z opisanego rozwiązania nie odnosi się wrażenia, że do napisania kodu asynchronicznego trzeba uczyć się nowego języka.

Jeśli dotarłeś już do tego miejsca, być może zainteresuje cię wyczerpujący artykuł na ten temat autorstwa Jamesa Longa.

Generatory wytyczają zatem nowy model programowania asynchronicznego, który jest o wiele bardziej przyjazny ludzkim umysłom. Prace w tym zakresie wciąż trwają. Jednym z wartych rozwijania udogodnień byłaby przejrzystsza składnia. Propozycja funkcji asynchronicznych, opierających się na obietnicach i generatorach, zainspirowanych analogicznymi rozwiązaniami w języku C#, będzie rozważana w pracach nad ES7.

Do czego mogę wykorzystać te szalone dodatki?

Już teraz możesz używać generatorów ES6, pisząc serwerowy kod dla środowiska io.js (a także Node po zdefiniowaniu opcji --harmony w wierszu poleceń).

Jeśli chodzi o kod przeglądarkowy, generatory ES6 są póki co obsługiwane jedynie w przeglądarkach Firefox 27 i Chrome 39 oraz ich nowszych wersjach. Aby móc już dziś używać generatorów w kodzie przeglądarkowym, trzeba skorzystać z kompilatora Babel lub Traceur, który przetłumaczy kod w standardzie ES6 na przyjazny przeglądarkom ES5.

A teraz kilka słów uznania dla zasłużonych osób: generatory zostały po raz pierwsze zaimplementowane w JS przez Brendana Eicha. Jego implementacja była w dużej mierze oparta na generatorach z Pythona, które były z kolei zainspirowane językiem Icon. Pierwsza wersja generatorów pojawiła się oficjalnie w Firefoksie 2.0 w 2006 roku. Droga do ich standaryzacji była wyboista, a ich składnia i zachowanie zmieniały się kilkakrotnie. Generatory ES6 zostały zaimplementowane w przeglądarkach Firefox i Chrome przez hakera Andy’ego Wingo, a całą pracę sponsorował Bloomberg.

yield;

To nie wszystko jeśli chodzi o generatory. Nie powiedzieliśmy nic o metodach .throw() i .return(), opcjonalnym argumencie metody .next(), ani o składni wyrażenia yield*. 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.

Omówiliśmy dwa złożone zagadnienia z rzędu, więc w kolejnym artykule czas dać sobie nieco luzu. Czy nie byłoby miło powiedzieć o jakimś elemencie ES6, który nie zmieni życia programistów JS? O czymś prostym, łatwym i przydatnym? O czymś, co wywoła na twojej twarzy uśmiech? W ES6 znajdują się i takie elementy.

W następnym odcinku: coś, co od razu wkomponujesz w pisany na co dzień kod. Odwiedzaj nas zatem często, by dowiedzieć się, jak funkcjonują łańcuchy szablonowe w ES6.

Autor: Jason Orendorff

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

Tłumaczenie: Joanna Liana

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

Zobacz również:

1 komentarz

  1. Hej, linki do „kota” nie działają :(

    Odpowiedz

Dyskusja

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