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

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ł przezyield(albo zwrócił przezreturn, alboundefined).done-falsedopóki funkcja jeszcze nie skończyła;truegdy 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 yieldWię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):
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
.next(), żeby uruchomić generatorMechanika krok po kroku:
- Pierwsze
.next()startuje generator, leci aż doyield "Jak masz na imię?". Pauza.valueto ten string. .next("Adam")wznawia funkcję. Wartość"Adam"zostaje podstawiona w miejsce wyrażeniayield "Jak masz na imię?", więcimie = "Adam". Funkcja leci dalej..next(30)analogicznie -30ląduje jako wynik drugiegoyield,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:
-
Generator to funkcja, która między kolejnymi wywołaniami pamięta, gdzie się zatrzymała.
function*+yielddaje 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. -
Każde
.next()to jeden krok wykonania. Wartości lecą w obie strony, dwiema oddzielnymi ścieżkami:- Output: piszesz
yield Xw generatorze, czytaszXw.next().valuena zewnątrz. - Input: piszesz
.next(Y)na zewnątrz, generator dostajeYjako wynik wyrażeniayield.
Dlatego generator nie tylko produkuje, ale i konsumuje dane.
- Output: piszesz
-
Na tym mechanizmie stoi kilka rzeczy: dane na żądanie, oszczędność pamięci, własne iteratory, i przede wszystkim
async/await- czyli generator zyield <Promise>w środku, sterowany przez auto-runner.
Komentarze
Wczytywanie komentarzy…