ES6 wprowadził nowy sposób pracy z funkcjami i iteratorami w postaci Generatorów (lub funkcji generatora). Generator to funkcja, która może zatrzymać się w połowie drogi, a następnie kontynuować od miejsca, w którym się zatrzymała. W skrócie, generator wydaje się być funkcją, ale zachowuje się jak iterator.
Fun Fact: async/await
może być oparty na generatorach. Przeczytaj więcej tutaj.
Generatory są nierozerwalnie związane z iteratorami. Jeśli nie wiesz o iteratorach, oto artykuł, który pomoże ci lepiej je zrozumieć.
Oto prosta analogia, aby mieć intuicję co do generatorów, zanim przejdziemy do szczegółów technicznych.
Wyobraź sobie, że czytasz trzymający w napięciu techno-thriller. Wszystko pochłonięte na stronach książki, ledwo słyszysz dzwonek do drzwi. To dostawca pizzy. Wstajesz, aby otworzyć drzwi. Jednak zanim to zrobisz, ustawiasz zakładkę na ostatniej stronie, którą czytałeś. Mentalnie zapisujesz wydarzenia z fabuły. Następnie idziesz po pizzę. Po powrocie do pokoju, zaczynasz książkę od strony, na której ustawiłeś zakładkę. Nie zaczynasz jej ponownie od pierwszej strony. W pewnym sensie działałeś jako funkcja generatora.
Zobaczmy, jak możemy wykorzystać generatory do rozwiązania niektórych typowych problemów podczas programowania. Ale zanim to nastąpi, zdefiniujmy, czym są generatory.
Czym są generatory?
Zwykła funkcja, taka jak ta, nie może zostać zatrzymana, zanim nie zakończy swojego zadania, tzn. nie zostanie wykonana jej ostatnia linia. Podąża ona za czymś, co nazywa się modelem run-to-completion.
function normalFunc() {
console.log('I')
console.log('cannot')
console.log('be')
console.log('stopped.')
}
Jedynym sposobem na wyjście z normalFunc
jest return
wykonanie z niej, lub throw
wykonanie błędu. Jeśli wywołasz funkcję ponownie, rozpocznie ona wykonywanie od nowa.
W przeciwieństwie do tego, generator jest funkcją, która może zatrzymać się w połowie drogi, a następnie kontynuować od miejsca, w którym się zatrzymała.
Oto kilka innych popularnych definicji generatorów –
- Generatory są specjalną klasą funkcji, które upraszczają zadanie pisania iteratorów.
- Generator jest funkcją, która produkuje sekwencję wyników zamiast pojedynczej wartości, czyli generujesz serię wartości.
W JavaScript, generator jest funkcją, która zwraca obiekt, na którym można wywołać next()
. Każde wywołanie funkcji next()
zwróci obiekt o kształcie –
{
value: Any,
done: true|false
}
Właściwość value
będzie zawierała wartość. Właściwość done
jest albo true
albo false
. Kiedy done
staje się true
, generator zatrzymuje się i nie będzie generował żadnych więcej wartości.
Oto ilustracja tego samego –
Zauważ przerywaną strzałkę, która zamyka pętlę yield-resume-yield tuż przed Finish w Generators części obrazu. Istnieje możliwość, że generator może nigdy się nie zakończyć. Zobaczymy przykład później.
Tworzenie generatora
Zobaczmy, jak możemy utworzyć generator w JavaScript –
function * generatorFunction() { // Line 1
console.log('This will be executed first.');
yield 'Hello, '; // Line 2 console.log('I will be printed after the pause');
yield 'World!';
}const generatorObject = generatorFunction(); // Line 3console.log(generatorObject.next().value); // Line 4
console.log(generatorObject.next().value); // Line 5
console.log(generatorObject.next().value); // Line 6// This will be executed first.
// Hello,
// I will be printed after the pause
// World!
// undefined
Skup się na pogrubionych częściach. Do tworzenia funkcji generatora używamy składni function *
zamiast po prostu function
. Pomiędzy słowem kluczowym function
, *
i nazwą funkcji może znajdować się dowolna liczba spacji. Ponieważ jest to tylko funkcja, możesz jej użyć wszędzie tam, gdzie można użyć funkcji, czyli wewnątrz obiektów i metod klasowych.
Wewnątrz ciała funkcji nie mamy return
. Zamiast tego mamy kolejne słowo kluczowe yield
(linia 2). Jest to operator, za pomocą którego generator może się wstrzymać. Za każdym razem, gdy generator napotka yield
, „zwraca” podaną po nim wartość. W tym przypadku, Hello,
jest zwracane. Jednakże, nie mówimy „zwrócił” w kontekście generatorów. Mówimy „the generator has yielded Hello,
„.
Możemy również powrócić z generatora. Jednak return
ustawia właściwość done
na true
, po czym generator nie może już generować żadnych wartości.
function * generatorFunc() {
yield 'a';
return 'b'; // Generator ends here.
yield 'a'; // Will never be executed.
}
W linii 3 tworzymy obiekt generatora. Wygląda na to, że wywołujemy funkcję generatorFunction
. Tak właśnie jest! Różnica polega na tym, że zamiast zwracać dowolną wartość, funkcja generatora zawsze zwraca obiekt generatora. Obiekt generatora jest iteratorem. Można go więc używać w pętlach for-of
lub innych funkcjach przyjmujących iterable.
W linii 4 wywołujemy metodę next()
na obiekcie generatorObject
. Wraz z tym wywołaniem generator rozpoczyna wykonywanie. Najpierw wykonuje console.log
metodę This will be executed first.
, a następnie napotyka yield 'Hello, '
. Generator oddaje wartość jako obiekt { value: 'Hello, ', done: false }
i zawiesza się/pauzuje. Teraz czeka na kolejne wywołanie.
W linii 5 ponownie wywołujemy next()
. Tym razem generator budzi się i rozpoczyna wykonywanie od miejsca, w którym się zatrzymał. Następną linią, którą znajduje jest console.log
. Rejestruje on ciąg I will be printed after the pause
. Kolejny yield
jest napotkany. Wartość jest dostarczana jako obiekt { value: 'World!', done: false }
. Wyodrębniamy właściwość value
i zapisujemy ją w dzienniku. Generator ponownie śpi.
W linii 6 ponownie wywołujemy next()
. Tym razem nie ma już więcej linii do wykonania. Pamiętaj, że każda funkcja niejawnie zwraca undefined
, jeśli nie podano instrukcji return. Dlatego też generator zwraca (zamiast zwracać) obiekt { value: undefined, done: true}
. Obiekt done
jest ustawiany na true
. To sygnalizuje koniec działania tego generatora. Teraz nie może on generować więcej wartości ani wznowić działania, ponieważ nie ma już żadnych instrukcji do wykonania.
Będziemy musieli stworzyć nowy obiekt generatora, aby uruchomić generator ponownie.
Usługi generatorów
Istnieje wiele wspaniałych przypadków użycia generatorów. Zobaczmy kilka z nich.
Implementacja Iterables
Gdy implementujesz iterator, musisz ręcznie utworzyć obiekt iteratora z metodą next()
. Ponadto, musisz ręcznie zapisać jego stan. Często zdarza się, że jest to naprawdę trudne do zrobienia. Ponieważ generatory są również iterable, mogą być używane do implementacji iterables bez dodatkowego kodu kotła. Zobaczmy prosty przykład.
Problem: Chcemy stworzyć niestandardową iterowalną, która zwraca This
, is
i iterable.
. Oto jedna implementacja przy użyciu iteratorów –
const iterableObj = {
() {
let step = 0;
return {
next() {
step++;
if (step === 1) {
return { value: 'This', done: false};
} else if (step === 2) {
return { value: 'is', done: false};
} else if (step === 3) {
return { value: 'iterable.', done: false};
}
return { value: '', done: true };
}
}
},
}for (const val of iterableObj) {
console.log(val);
}// This
// is
// iterable.
Oto ta sama rzecz przy użyciu generatorów –
function * iterableObj() {
yield 'This';
yield 'is';
yield 'iterable.'
}for (const val of iterableObj()) {
console.log(val);
}// This
// is
// iterable.
Możesz porównać obie wersje. To prawda, że jest to trochę wymyślony przykład. Nie musimy się martwić o Symbol.iterator
next()
.next()
, czyli { value: 'This', done: false }
.step
. Jej wartość określała, co zostało wyprowadzone z iterowalnej. W generatorze nie musieliśmy robić nic takiego.Poprawiona funkcjonalność async
Kod wykorzystujący obietnice i wywołania zwrotne, taki jak –
function fetchJson(url) {
return fetch(url)
.then(request => request.text())
.then(text => {
return JSON.parse(text);
})
.catch(error => {
console.log(`ERROR: ${error.stack}`);
});
}
, można napisać jako (z pomocą bibliotek takich jak co.js)-
const fetchJson = co.wrap(function * (url) {
try {
let request = yield fetch(url);
let text = yield request.text();
return JSON.parse(text);
}
catch (error) {
console.log(`ERROR: ${error.stack}`);
}
});
Niektórzy czytelnicy mogli zauważyć, że jest to analogiczne do użycia async/await
. To nie jest przypadek. async/await
może podążać za podobną strategią i zastępuje plon z await
w przypadkach, gdy obietnice są zaangażowane. Może być oparty na generatorach. Zobacz ten komentarz, aby uzyskać więcej informacji.
Infinite Data Streams
Możliwe jest tworzenie generatorów, które nigdy się nie kończą. Rozważmy następujący przykład –
function * naturalNumbers() {
let num = 1;
while (true) {
yield num;
num = num + 1
}
}const numbers = naturalNumbers();console.log(numbers.next().value)
console.log(numbers.next().value)// 1
// 2
Utworzymy generator naturalNumbers
. Wewnątrz funkcji mamy nieskończoną pętlę while
. W tej pętli yield
wykonujemy num
. Gdy generator się poddaje, zostaje zawieszony. Gdy ponownie wywołamy next()
, generator budzi się, kontynuuje od miejsca, w którym został zawieszony (w tym przypadku yield num
) i wykonuje się do momentu napotkania kolejnego yield
lub zakończenia generatora. Ponieważ następną instrukcją jest num = num + 1
, aktualizuje ona num
. Następnie, przechodzi na szczyt pętli while. Warunek wciąż jest prawdziwy. Napotyka następną linię yield num
. Dostarcza zaktualizowaną num
i zawiesza się. Trwa to tak długo, jak chcesz.
Generatory jako obserwatorzy
Generatory mogą również otrzymywać wartości za pomocą funkcji next(val)
. Wtedy generator jest nazywany obserwatorem, ponieważ budzi się, gdy otrzymuje nowe wartości. W pewnym sensie, obserwuje on wartości i działa, gdy je otrzyma. Możesz przeczytać więcej o tym wzorcu tutaj.
Wady generatorów
Jak widać na przykładzie Infinite Data Streams, jest to możliwe tylko dzięki leniwej ewaluacji. Leniwa ewaluacja jest modelem ewaluacji, który opóźnia ewaluację wyrażenia do momentu, gdy jego wartość jest potrzebna. To znaczy, jeśli nie potrzebujemy wartości, to ona nie będzie istnieć. Jest ona obliczana w miarę naszego zapotrzebowania na nią. Zobaczmy przykład –
function * powerSeries(number, power) {
let base = number;
while(true) {
yield Math.pow(base, power);
base++;
}
}
The powerSeries
daje serię liczby podniesionej do potęgi. Na przykład, szereg potęgowy liczby 3 podniesionej do 2 wyniesie 9(3²) 16(4²) 25(5²) 36(6²) 49(7²). Kiedy wykonujemy const powersOf2 = powerSeries(3, 2);
po prostu tworzymy obiekt generatora. Żadna z wartości nie została obliczona. Teraz, gdybyśmy wywołali next()
, 9 zostałoby obliczone i zwrócone.
Wydajność pamięciowa
Bezpośrednią konsekwencją Lazy Evaluation jest to, że generatory są wydajne pamięciowo. Generujemy tylko te wartości, które są potrzebne. Z normalnymi funkcjami, musieliśmy wstępnie wygenerować wszystkie wartości i trzymać je w pobliżu na wypadek gdybyśmy użyli ich później. Jednak dzięki generatorom możemy odroczyć obliczenia do czasu, aż będą nam potrzebne.
Możemy tworzyć funkcje kombinatoryczne, aby działać na generatorach. Kombinatory są funkcjami, które łączą istniejące iterable, aby stworzyć nowe.Jednym z takich kombinatorów jest take
. Pobiera on pierwsze n
elementy iterowalne. Oto jedna z implementacji –
function * take(n, iter) {
let index = 0;
for (const val of iter) {
if (index >= n) {
return;
}
index = index + 1;
yield val;
}
}
Tutaj kilka ciekawych przypadków użycia take
–
take(3, )// a b ctake(7, naturalNumbers());// 1 2 3 4 5 6 7take(5, powerSeries(3, 2));// 9 16 25 36 49
Tutaj implementacja biblioteki cyklicznej (bez funkcji odwracania).
function * cycled(iter) {
const arrOfValues =
while (true) {
for (const val of arrOfValues) {
yield val
}
}
}console.log(...take(10, cycled(take(3, naturalNumbers()))))// 1 2 3 1 2 3 1 2 3 1
Caveats
Jest kilka punktów, o których powinieneś pamiętać podczas programowania z użyciem generatorów.
- Obiekty generatorów mają dostęp tylko jednorazowy. Kiedy już wyczerpałeś wszystkie wartości, nie możesz ponownie nad nimi operować. Aby ponownie wygenerować wartości, musisz utworzyć nowy obiekt generatora.
const numbers = naturalNumbers();console.log(...take(10, numbers)) // 1 2 3 4 5 6 7 8 9 10
console.log(...take(10, numbers)) // This will not give any data
- Obiekty generatora nie pozwalają na losowy dostęp, jak to jest możliwe w przypadku tablic. Ponieważ wartości są generowane jeden po drugim, dostęp do losowej wartości doprowadziłby do obliczenia wartości do tego elementu. Stąd, nie jest to dostęp losowy.
Wniosek
Wiele rzeczy jest jeszcze do omówienia w generatorach. Rzeczy takich jak yield *
, return()
i throw()
. Generatory umożliwiają również tworzenie coroutines. Wymieniłem kilka referencji, które możesz przeczytać, aby uzyskać dalsze zrozumienie generatorów.
Możesz udać się na stronę Pythona itertools i zobaczyć niektóre z narzędzi, które pozwalają na pracę z iteratorami i generatorami. Jako ćwiczenie, możesz sam zaimplementować te narzędzia.