Protokół iteratora
Wiele funkcji JavaScript, takich jak for...of, spread (...), destrukturyzacja, Array.from czy Promise.all, opiera się na jednym wspólnym mechanizmie: protokole iteratora. Gdy go zrozumiesz, wszystkie zaczną wyglądać jak warianty tego samego pomysłu.
Iterator to dowolny obiekt z metodą next(), która zwraca { value, done }:
Wywołuj next() raz za razem. Każde wywołanie zwraca kolejną wartość i flagę done. Gdy done ma wartość true, sekwencja się kończy. To cały protokół: jedna metoda i jedna wartość logiczna.
Obiekt iterowalny kontra iterator
Jest jeszcze drugie, powiązane pojęcie. Obiekt iterowalny (iterable) to wszystko, co potrafi wyprodukować iterator. Robi to przez metodę zapisaną pod specjalnym kluczem: Symbol.iterator.
Tablice są iterowalne. Wywołanie numbers[Symbol.iterator]() zwraca świeży iterator. Stringi, Map, Set i arguments też są iterowalne i właśnie dlatego for...of działa na nich wszystkich.
Ten podział ma znaczenie: obiekt iterowalny to kolekcja, a iterator to kursor. Możesz poprosić obiekt iterowalny o dowolnie wiele niezależnych kursorów.
Dlaczego for...of działa
for...of to po prostu cukier składniowy nad protokołem iteratora. Pod spodem wywołuje Symbol.iterator, a potem next(), dopóki done nie będzie true:
Spread i destrukturyzacja robią to samo: przechodzą po iteratorze, aż się skończy:
Każdy obiekt, który zbudujesz i który implementuje Symbol.iterator, za darmo działa ze wszystkimi tymi funkcjami.
Własny obiekt iterowalny
Zróbmy obiekt range, który zwraca liczby od start do end:
Kilka rzeczy wartych uwagi:
[Symbol.iterator]()używa obliczanej nazwy metody. Kluczem jest sam symbol, a nie string"Symbol.iterator".- Każde wywołanie
[Symbol.iterator]()zwraca zupełnie nowy iterator z własnymcurrent. Dzięki temu możesz przejść porangedwa razy i nie zostanie on „zużyty”. - Zwracany iterator potrzebuje tylko
next(). Nic więcej.
To działa, ale jest rozwlekłe. Jest znacznie lepszy sposób.
Wchodzą generatory
Funkcję generatora deklaruje się przez function* (zwróć uwagę na gwiazdkę). Zamiast wykonać się do końca, może wstrzymać się na wyrażeniu yield i wznowić później. Jej wywołanie nie wykonuje ciała, tylko oddaje obiekt generatora, który jest jednocześnie iteratorem i obiektem iterowalnym:
Każde next() wykonuje ciało, aż trafi na yield, wstrzymuje się i zwraca { value, done: false }. Gdy funkcja się kończy, dostajesz { value: undefined, done: true }.
A ponieważ generatory są iterowalne, działają ze wszystkim z poprzedniej sekcji:
range przepisany na generator
Porównaj rozwlekłą wersję powyżej z tą:
I tyle. * przed [Symbol.iterator] zamienia ją w metodę generatora. yield i zastępuje cały ręcznie pisany obiekt iteratora. Żadnego next, żadnego done, żadnego ryzyka błędu o jeden: zwykła pętla z yield zamiast push.
Właśnie dlatego istnieją generatory. Zamieniają „napisz iterator” na „napisz funkcję, która używa yield”.
yield kontra return
yield wstrzymuje, return kończy. yield możesz użyć dowolnie wiele razy, a generator wznawia działanie tam, gdzie skończył:
return wewnątrz generatora pojawia się jako { value: "done", done: true } w wywołaniu, które go kończy. for...of i spread ignorują tę zwróconą wartość: konsumują tylko elementy, w których done ma wartość false. Nie używaj więc return value, żeby przemycić ostatni element do pętli, bo zostanie pominięty.
Leniwe i nieskończone sekwencje
Generatory produkują wartości na żądanie, jedną po drugiej. Dzięki temu możesz reprezentować sekwencje, które jako tablice byłyby niemożliwe:
Pętla to dosłownie while (true), a mimo to program się kończy, bo generator posuwa się naprzód tylko wtedy, gdy ktoś poprosi o kolejną wartość. Możesz wziąć pierwsze N elementów, zatrzymać się, a reszta nigdy się nie wykona:
take sam jest generatorem, który opakowuje inny generator. Składanie generatorów w ten sposób to duża część ich uroku: małe kawałki, z których każdy robi jedną rzecz.
Delegowanie przez yield*
Jeśli generator ma oddać wszystko z innego obiektu iterowalnego, yield* deleguje do niego:
yield* działa z każdym obiektem iterowalnym: tablicami, zbiorami, innymi generatorami. Przekazuje dalej każdy element po kolei. To odpowiednik operatora spread w świecie iteratorów.
Asynchroniczne generatory w skrócie
Generator zadeklarowany jako async function* może oddawać przez yield wartości, na które trzeba poczekać. Przydaje się to przy strumieniowaniu danych z API albo czytaniu pliku kawałkami. Konsumujesz go przez for await...of:
async function* paginate(url) {
let next = url;
while (next) {
const res = await fetch(next);
const page = await res.json();
for (const item of page.items) yield item;
next = page.nextUrl;
}
}
for await (const item of paginate("/api/users")) {
console.log(item);
}
Tego fragmentu nie da się tutaj uruchomić (potrzebuje prawdziwego endpointu), ale warto wiedzieć, że taki kształt istnieje. Gdy rozumiesz zwykłe generatory, asynchroniczne to ten sam pomysł z dodanym await.
Kiedy sięgnąć po generator
Użyj go, gdy:
- Sekwencja jest lub może być nieskończona: identyfikatory, znaczniki czasu, opóźnienia ponowień.
- Wyprodukowanie wszystkich wartości jest kosztowne, a konsument może zatrzymać się wcześniej.
- Implementujesz
Symbol.iteratorwe własnym obiekcie. Prawie zawsze wychodzi krócej niż ręczne pisanie obiektu{ next() }. - Chcesz składać strumieniowe przekształcenia (
take,filter,map) bez budowania pośrednich tablic.
Po zwykłą tablicę sięgnij, gdy dane już są w pamięci i jest ich mało. Generatory nie są darmowe: mechanizm wstrzymywania i wznawiania funkcji ma swój narzut, a stack trace przechodzący przez kod generatora bywa trudniejszy do odczytania.
Dalej: symbole
Symbol.iterator to pierwszy symbol, jaki poznaje większość osób, ale daleko mu do jedynego. Symbole to typ prosty zaprojektowany właśnie do takich punktów rozszerzeń: unikalne klucze, dzięki którym język i twój własny kod mogą podpinać się pod obiekty bez kolizji ze zwykłymi nazwami właściwości. O nich jest następna strona.
Najczęściej zadawane pytania
Czym różni się obiekt iterowalny od iteratora w JavaScript?
Obiekt iterowalny (iterable) to dowolny obiekt z metodą Symbol.iterator, która zwraca iterator. Iterator to obiekt, który faktycznie produkuje wartości: ma metodę next() zwracającą { value, done }. Tablice, stringi, Map i Set są iterowalne. Wywołanie ich metody Symbol.iterator daje iterator, po którym możesz przechodzić krok po kroku.
Czym jest funkcja generatora w JavaScript?
To funkcja zadeklarowana przez function*, która leniwie produkuje wartości za pomocą yield. Jej wywołanie nie uruchamia ciała, tylko zwraca obiekt generatora, który jest jednocześnie iteratorem i obiektem iterowalnym. Każde wywołanie next() wykonuje kod do najbliższego yield, wstrzymuje się i zwraca podaną wartość.
Czym różni się yield od return w generatorze?
yield wstrzymuje generator i oddaje wartość, ale funkcja może wznowić działanie w tym samym miejscu przy kolejnym next(). return kończy generator na dobre: ustawia done: true i żadne kolejne wartości już nie wychodzą. yield możesz użyć wiele razy, a return sensownie tylko raz.
Kiedy użyć generatora zamiast tablicy?
Gdy sekwencja jest nieskończona, kosztowna do obliczenia albo potrzebujesz tylko części wartości. Generator produkuje elementy pojedynczo, na żądanie, więc możesz reprezentować niekończący się strumień identyfikatorów albo stronicowane wyniki z API bez tworzenia wszystkiego z góry. Jeśli masz już małą, stałą tablicę, po prostu użyj tablicy.