Przejdź do treści

Generators w JavaScript - funkcja, która umie się zatrzymać

Profile photo of Adrian Zawadzki

Adrian Zawadzki

10 min czytania

Zwykła funkcja w JS leci od góry do dołu, zwraca wynik i znika z pamięci. Generator jest inny - oznaczony function*, na każdym yield zatrzymuje swoje wykonanie, oddaje wartość i czeka na kolejne .next(). Mała zmiana w runtime, ale to ona stoi za sekwencjami liczonymi na żądanie i pod spodem async/await.

Większość danych w JS to gotowe kolekcje - tablica, Set, Map. Lecisz po nich pętlą, każdy element już leży w pamięci, policzony z góry.

Czasem to nie wystarcza - bo danych jeszcze nie ma, jest ich za dużo żeby trzymać w pamięci, albo każdy element coś kosztuje (request, obliczenie) i nie wiesz, czy w ogóle ci się przyda.

Generator jest czymś innym: funkcja, która produkuje wartości pojedynczo, na żądanie. Nie zwraca jednego wyniku na końcu, tylko oddaje kawałki w trakcie. Ty pytasz przez .next(), ona odpowiada, a w międzyczasie pamięta, gdzie skończyła.

#Funkcja, która umie się zatrzymać

Zwykła funkcja w JS jest krótkotrwała. Wywołasz ją, wleci na call stack, leci od góry do dołu, zwraca wynik i znika. Nie ma stanu „w połowie”.

Generator zmienia w tym dwie rzeczy. Pierwsza - składnia function* (z gwiazdką) i słowo kluczowe yield, które działa jak return z jedną różnicą: zachowuje kontekst funkcji do następnego wywołania. Druga - samo wywołanie generatora nie uruchamia jego kodu. Zwraca tylko obiekt (generator object) z metodą .next(), która zaraz uruchomi to ciało za nas:

function* counter() {
  console.log("startuję");
  yield 1;
  console.log("idę dalej");
  yield 2;
  console.log("ostatni krok");
  yield 3;
}

const it = counter();
console.log("po wywołaniu counter()");
console.log(it.next());

// po wywołaniu counter()    ← najpierw to
// startuję                   ← dopiero teraz ciało ruszyło
// { value: 1, done: false }

Ciało funkcji ruszyło dopiero po pierwszym .next(). Wywołanie generatora nie uruchamia kodu - zwraca obiekt (przypisany wyżej do it), który dopiero ma to zrobić, krok po kroku.

#Każde .next() to jeden krok

.next() zwraca obiekt { value, done }:

  • value - to, co generator wyemitował przez yield (albo zwrócił przez return, albo undefined).
  • done - false dopóki funkcja jeszcze nie skończyła; true gdy doleciała do końca.
const it = counter();
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { value: 3, done: false }
it.next(); // { value: undefined, done: true }

Każde .next() wykonuje kawałek funkcji aż do najbliższego yield, oddaje wartość i pauzuje. Gdy nie ma już więcej yield-ów, kolejne .next() zwracają { value: undefined, done: true } - bez błędu.

#Co znaczy „zawieszony kontekst”

Gdy generator wykona yield x, jego kontekst wykonawczy nie znika. Zmienne lokalne, pozycja w kodzie, scope chain - wszystko zostaje zachowane, gotowe do wznowienia.

function* state() {
  let x = 10;
  yield x;
  x += 5;
  yield x;
}

const it = state();
it.next(); // { value: 10, done: false }
it.next(); // { value: 15, done: false }   <- x przeżył pierwszy yield

Większość rzeczy w runtime jest binarna: kontekst albo istnieje na stosie, albo go nie ma. Generator ma trzeci stan: kontekst istnieje, ale jest zawieszony. Czeka na sygnał, że ma ruszyć dalej.

#Komunikacja w obie strony: .next(value)

Tu robi się ciekawie. yield to nie tylko emisja wartości na zewnątrz. To też wyrażenie, do którego można podstawić wartość przez .next(value).

Najlepiej zobaczyć to klikając. Poniżej masz prawdziwy generator dialog() - klikaj .next() (pierwsze wywołanie ignoruje argument), potem wpisuj wartości i wciskaj .next("..."). Patrz na cursor po lewej (gdzie generator stoi zawieszony) i na panel zmienne lokalne po prawej (jak twój input ląduje w imie, potem w wiek):

kod
function* dialog() {  const imie = yield 'Jak masz na imię?';  const wiek = yield `Cześć ${imie}, ile masz lat?`;  return `${imie}, ${wiek} lat`;} const it = dialog();   // <- iterator, na którym wołasz .next() poniżej
stanready
zmienne lokalne
brak
historia wywołań
kliknij .next(), żeby uruchomić generator

Mechanika krok po kroku:

  1. Pierwsze .next() startuje generator, leci aż do yield "Jak masz na imię?". Pauza. value to ten string.
  2. .next("Adam") wznawia funkcję. Wartość "Adam" zostaje podstawiona w miejsce wyrażenia yield "Jak masz na imię?", więc imie = "Adam". Funkcja leci dalej.
  3. .next(30) analogicznie - 30 ląduje jako wynik drugiego yield, wiek = 30.

Argument przekazany do pierwszego .next() jest ignorowany, bo generator nie stoi jeszcze na żadnym yield - nie ma do czego podstawić wartości.

To dokładnie ten mechanizm, którego potrzebował async/await. Wyobraź sobie pomocniczą funkcję (zwaną zwykle auto-runner), która siedzi obok generatora i sama woła za ciebie .next() w pętli. Gdy generator zatrzymuje się na yield <jakiś Promise>, auto-runner czeka, aż ten Promise się rozstrzygnie, i wtedy podstawia wynik z powrotem przez .next(wynik). Generator widzi tę wartość po prawej stronie wyrażenia yield - czyli dokładnie tam, gdzie w async function masz await. Cała magia async/await, schowana za jednym słowem kluczowym.

#Iterator protocol - czemu for...of działa

for...of, spread i destrukturyzacja zawsze działają tak samo: wołają na obiekcie .next() w pętli, aż dostaną done: true. Czyli wystarczy, że obiekt ma .next() zwracające { value, done }, żeby JS umiał go iterować. Te zasady nazywają się iterator protocol.

Generator object spełnia je z automatu, więc pasuje wszędzie, gdzie JS spodziewa się iterowalnego:

for (const n of counter()) console.log(n); // 1, 2, 3

[...counter()]; // [1, 2, 3]
const [a, b] = counter(); // a=1, b=2
Array.from(counter()); // [1, 2, 3]

Generator jest jednorazowy. Po dotarciu do done: true nie da się go „przewinąć”. Żeby dostać świeży iterator, ponownie wywołujesz tę funkcję - np. dialog().

#Dwa zastosowania: dane na żądanie i fundament async/await

Pierwsze zastosowanie: dane, których nie chcesz mieć wszystkich w pamięci od razu. Tablica wymaga, żeby wszystkie elementy istniały z góry. Generator nie - następną wartość liczy dopiero wtedy, gdy ktoś o nią pyta. W teorii nazywa się to lazy evaluation:

function* naturals() {
  let n = 1;
  while (true) {
    yield n;
    n += 1;
  }
}

const it = naturals();
it.next().value; // 1
it.next().value; // 2
it.next().value; // 3
// ...można by tak nieskończenie - generator policzy dopiero,
// gdy ktoś poprosi przez .next()

Co z tego masz: dwie rzeczy. Po pierwsze, możesz mieć ciąg, którego nie da się trzymać w tablicy - nieskończony (Fibonacci, strumień zdarzeń) albo za duży na pamięć (plik 10 GB). Po drugie, pamięć zostaje stała: generator trzyma tylko swój aktualny stan, więc czytanie pliku 10 GB linijka po linijce kosztuje tyle, co 10 KB.

Drugie zastosowanie to fundament pod async/await. Pod spodem async function to dokładnie ten sam wzorzec z sekcji wyżej: generator + auto-runner czekający na Promise. await to po prostu yield. Zanim weszło natywne async/await, składało się to ręcznie z generatorów - np. biblioteką co.

#Puenta

Sprowadza się to do trzech rzeczy:

  1. Generator to funkcja, która między kolejnymi wywołaniami pamięta, gdzie się zatrzymała. function* + yield daje runtime’owi sygnał, żeby zachować całe wykonanie funkcji - pozycję w kodzie, lokalne zmienne, scope chain - tak, żeby kolejne .next() mogło wznowić ją dokładnie z tego miejsca. To inna rzecz niż closure: closure pamięta zmienne, generator pamięta cały stan wykonania.

  2. Każde .next() to jeden krok wykonania. Wartości lecą w obie strony, dwiema oddzielnymi ścieżkami:

    • Output: piszesz yield X w generatorze, czytasz X w .next().value na zewnątrz.
    • Input: piszesz .next(Y) na zewnątrz, generator dostaje Y jako wynik wyrażenia yield.

    Dlatego generator nie tylko produkuje, ale i konsumuje dane.

  3. Na tym mechanizmie stoi kilka rzeczy: dane na żądanie, oszczędność pamięci, własne iteratory, i przede wszystkim async/await - czyli generator z yield <Promise> w środku, sterowany przez auto-runner.

Komentarze

Wczytywanie komentarzy…

Dodaj komentarz