Kapitel 4. Generatoren
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
In Kapitel 2 haben wir zwei wesentliche Nachteile der asynchronen Ablaufsteuerung mit Callbacks herausgearbeitet:
-
Rückrufbasierte Asynchronität passt nicht dazu, wie unser Gehirn die Schritte einer Aufgabe plant.
-
Rückrufe sind wegen der Umkehrung der Kontrolle nicht vertrauenswürdig oder zusammensetzbar.
In Kapitel 3 haben wir beschrieben, wie Promises die Umkehrung der Kontrolle über Rückrufe rückgängig machen und die Vertrauenswürdigkeit/Kompatibilität wiederherstellen.
Jetzt wenden wir uns der asynchronen Ablaufsteuerung in einer sequenziellen, synchronen Weise zu. Die "Magie", die dies möglich macht, sind die ES6-Generatoren.
Breaking Run-to-Completion
In Kapitel 1 haben wir eine Erwartung erläutert, auf die sich JS-Entwickler in ihrem Code fast durchgängig verlassen: Sobald eine Funktion ausgeführt wird, läuft sie, bis sie abgeschlossen ist, und kein anderer Code kann sie unterbrechen und dazwischen laufen.
So bizarr es auch klingen mag, ES6 führt eine neue Art von Funktion ein, die sich nicht mit dem Run-to-Completion-Verhalten verhält. Diese neue Art von Funktion wird Generator genannt.
Um die Auswirkungen zu verstehen, lass uns dieses Beispiel betrachten:
var
x
=
1
;
function
foo
()
{
x
++
;
bar
();
// <-- what about this line?
console
.
log
(
"x:"
,
x
);
}
function
bar
()
{
x
++
;
}
foo
();
// x: 3
In diesem Beispiel wissen wir mit Sicherheit, dass bar()
zwischen x++
undconsole.log(x)
liegt. Aber was wäre, wenn bar()
nicht da wäre? Das Ergebnis wäre dann natürlich 2
statt 3
.
Jetzt lass uns dein Gehirn verdrehen. Was wäre, wenn bar()
nicht vorhanden wäre, aber trotzdem irgendwie zwischen den Anweisungen x++
und console.log(x)
laufen könnte? Wie wäre das möglich?
In preemptiven Multithreading-Sprachen wäre es möglich, dass bar()
genau im richtigen Moment zwischen diesen beiden Anweisungen unterbrochen und ausgeführt wird. Aber JS ist weder preemptiv noch (derzeit) multithreaded. Dennoch ist eine kooperative Form dieser Unterbrechung (Gleichzeitigkeit) möglich, wenn foo()
selbst irgendwie eine Pause an dieser Stelle des Codes anzeigen könnte.
Hinweis
Ich verwende das Wort "kooperativ" nicht nur wegen der Verbindung zur klassischen Gleichzeitigkeitsterminologie (siehe Kapitel 1), sondern auch, weil die ES6-Syntax für die Angabe eines Pausenpunkts im Code yield
lautet, was auf eine höfliche kooperative Übergabe der Kontrolle hindeutet (siehe nächstes Snippet).
Hier ist der ES6-Code, um eine solche kooperative Gleichzeitigkeit zu erreichen:
var
x
=
1
;
function
*
foo
()
{
x
++
;
yield
;
// pause!
console
.
log
(
"x:"
,
x
);
}
function
bar
()
{
x
++
;
}
Hinweis
Du wirst wahrscheinlich in den meisten anderen JS-Dokumentationen/Codes sehen, dass eine Generator-Deklaration als function* foo() { .. }
formatiert wird, anstatt wie hier mit function *foo() { .. }
- der einzige Unterschied ist die stilistische Positionierung des *
. Die beiden Formen sind funktionell/syntaktisch identisch, ebenso wie eine drittefunction*foo() { .. }
(ohne Leerzeichen) Form. Es gibt Argumente für beide Formen, aber grundsätzlich bevorzuge ich function *foo..
, weil es dann passt, wenn ich beim Schreiben auf einen Generator mit *foo()
verweise. Wenn ich nurfoo()
sagen würde, wüsste man nicht so genau, ob ich von einem Generator oder einer regulären Funktion spreche. Das ist eine rein stilistische Vorliebe.
Wie können wir nun den Code in dem vorherigen Schnipsel so ausführen, dass bar()
an der Stelle von yield
innerhalb von *foo()
ausgeführt wird?
// construct an iterator `it` to control the generator
var
it
=
foo
();
// start `foo()` here!
it
.
next
();
x
;
// 2
bar
();
x
;
// 3
it
.
next
();
// x: 3
Okay, diese beiden Codeschnipsel enthalten eine Menge neuer und potenziell verwirrender Dinge, wir müssen uns also durch einiges durcharbeiten. Aber bevor wir die verschiedenen Mechanismen/Syntaxen der ES6-Generatoren erklären, lass uns erst einmal den Verhaltensablauf durchgehen:
-
Die Operation
it = foo()
führt den*foo()
Generator noch nicht aus, sondern konstruiert lediglich einen Iterator, der seine Ausführung steuert. Mehr zu Iteratoren in Kürze. -
Die erste
it.next()
startet den Generator*foo()
und führt diex++
auf der ersten Zeile von*foo()
aus. -
*foo()
wird bei der Anweisungyield
angehalten, wodurch der erste Aufruf vonit.next()
beendet wird. Im Moment läuft*foo()
noch und ist aktiv, aber es befindet sich in einem Pausenzustand. -
Wir überprüfen den Wert von
x
, und der ist jetzt2
. -
Wir rufen
bar()
auf, dasx
wieder mitx++
inkrementiert. -
Wir sehen uns den Wert von
x
noch einmal an, und er lautet jetzt3
. -
Der letzte Aufruf
it.next()
setzt den Generator*foo()
an der Stelle fort, an der er angehalten wurde, und führt die Anweisungconsole.log(..)
aus, die den aktuellen Wert vonx
von3
verwendet.
Es ist klar, dass foo()
gestartet wurde, aber nicht bis zum Ende lief, sondern auf yield
pausierte. Wir nahmen foo()
später wieder auf und ließen es zu Ende laufen, aber das war gar nicht nötig.
Ein Generator ist also eine besondere Art von Funktion, die ein oder mehrere Male starten und stoppen kann und nicht unbedingt jemals enden muss. Es ist zwar noch nicht ganz klar, warum das so mächtig ist, aber im weiteren Verlauf dieses Kapitels wird das einer der grundlegenden Bausteine sein, die wir verwenden, um Generatoren als asynchrone Ablaufsteuerung als Muster für unseren Code zu konstruieren.
Eingabe und Ausgabe
Eine Generatorfunktion ist eine spezielle Funktion mit dem neuen Verarbeitungsmodell, auf das wir gerade angespielt haben. Aber es ist immer noch eine Funktion, was bedeutet, dass sich einige Grundprinzipien nicht geändert haben - nämlich, dass sie immer noch Argumente akzeptiert (auch Eingabe genannt) und dass sie immer noch einen Wert zurückgeben kann (auch Ausgabe genannt):
function
*
foo
(
x
,
y
)
{
return
x
*
y
;
}
var
it
=
foo
(
6
,
7
);
var
res
=
it
.
next
();
res
.
value
;
// 42
Wir übergeben die Argumente 6
und 7
als Parameter x
bzw. y
an *foo(..)
. Und *foo(..)
gibt den Wert 42
an den aufrufenden Code zurück.
Jetzt sehen wir einen Unterschied beim Aufruf des Generators im Vergleich zu einer normalen Funktion. foo(6,7)
sieht offensichtlich vertraut aus. Aber der*foo(..)
Generator wurde noch nicht wirklich ausgeführt, wie es bei einer Funktion der Fall wäre.
Stattdessen erstellen wir einfach ein Iterator-Objekt, das wir der Variablen it
zuweisen, um den *foo(..)
Generator zu steuern. Dann rufen wirit.next()
auf, das den *foo(..)
Generator anweist, von seiner aktuellen Position aus weiterzugehen und entweder am nächsten yield
oder am Ende des Generators anzuhalten.
Das Ergebnis dieses next(..)
Aufrufs ist ein Objekt mit einer value
Eigenschaft, die den Wert (wenn überhaupt) enthält, der von *foo(..)
zurückgegeben wurde. Mit anderen Worten: yield
hat während der Ausführung des Generators einen Wert gesendet, sozusagen als Zwischenschrittreturn
.
Auch hier wird noch nicht klar sein, warum wir dieses ganze indirekteIterator-Objekt brauchen, um den Generator zu steuern. Aber das kommt schon noch,versprochen.
Iterationsnachrichten
Generatoren akzeptieren nicht nur Argumente und haben Rückgabewerte, sondern sie verfügen auch über noch leistungsfähigere und überzeugendere Ein- und Ausgabemöglichkeiten, die über yield
und next(..)
zur Verfügung stehen.
Bedenke:
function
*
foo
(
x
)
{
var
y
=
x
*
(
yield
);
return
y
;
}
var
it
=
foo
(
6
);
// start `foo(..)`
it
.
next
();
var
res
=
it
.
next
(
7
);
res
.
value
;
// 42
Zuerst übergeben wir 6
als Parameter x
. Dann rufen wir it.next()
auf, und es startet *foo(..)
.
Innerhalb von *foo(..)
beginnt die var y = x ..
Anweisung mit der Verarbeitung, aber dann stößt sie auf einen yield
Ausdruck. An diesem Punkt hält*foo(..)
an (mitten in der Zuweisungsanweisung!) und fordert den aufrufenden Code auf, einen Ergebniswert für den Ausdruck yield
zu liefern. Als Nächstes rufen wir it.next( 7 )
auf, das den Wert 7
als Ergebnis des angehaltenen Ausdrucks yield
zurückgibt.
An diesem Punkt ist die Zuweisungsanweisung also im Wesentlichenvar y = 6 * 7
. Jetzt gibt return y
den 42
Wert als Ergebnis des it.next( 7 )
Aufrufs zurück.
Beachte etwas sehr Wichtiges, das aber auch leicht verwirrend sein kann, selbst für erfahrene JS-Entwickler: Je nach Perspektive gibt es eine Diskrepanz zwischen dem yield
und dem next(..)
Aufruf. In der Regel hast du einen next(..)
Aufruf mehr als yield
Anweisungen - der vorangegangene Schnipsel hat einen yield
und zwei next(..)
Aufrufe.
Warum die Unstimmigkeit?
Denn der erste next(..)
startet immer einen Generator und läuft zum ersten yield
. Aber es ist der zweite next(..)
Aufruf, der den ersten angehaltenen yield
Ausdruck erfüllt, und der dritte next(..)
würde den zweiten yield
erfüllen, und so weiter.
Die Geschichte von zwei Fragen
Es hängt davon ab, an welchen Code du in erster Linie denkst, ob du eine Diskrepanz wahrnimmst oder nicht.
Betrachte nur den Generatorcode:
var
y
=
x
*
(
yield
);
return
y
;
Diese erste yield
stellt im Grunde eine Frage: "Welchen Wert soll ich hier eingeben?"
Wer wird diese Frage beantworten? Nun, der erste next()
ist bereits gelaufen, um den Generator bis zu diesem Punkt zu bringen, also kann erdie Frage natürlich nicht beantworten. Also muss der zweite Aufruf von next(..)
die Frage beantworten, die der erste Aufruf von yield
gestellt hat.
Siehst du die Fehlanpassung von Sekunde zu Sekunde?
Aber lass uns die Perspektive wechseln. Betrachten wir es nicht aus der Sicht des Generators, sondern aus der Sicht des Iterators.
Um diese Perspektive richtig zu veranschaulichen, müssen wir auch erklären, dass Nachrichten in beide Richtungen gehen können -yield ..
als Ausdruck kann Nachrichten als Antwort auf next(..)
Aufrufe senden, und next(..)
kann Werte an einen pausierten yield
Ausdruck senden. Betrachte diesen leicht geänderten Code:
function
*
foo
(
x
)
{
var
y
=
x
*
(
yield
"Hello"
);
// <-- yield a value!
return
y
;
}
var
it
=
foo
(
6
);
var
res
=
it
.
next
();
// first `next()`, don't pass anything
res
.
value
;
// "Hello"
res
=
it
.
next
(
7
);
// pass `7` to waiting `yield`
res
.
value
;
// 42
yield ..
und next(..)
bilden während der Ausführung des Generators ein wechselseitiges Nachrichtenübermittlungssystem.
Wenn du also nur den Iterator-Code betrachtest:
var
res
=
it
.
next
();
// first `next()`, don't pass anything
res
.
value
;
// "Hello"
res
=
it
.
next
(
7
);
// pass `7` to waiting `yield`
res
.
value
;
// 42
Hinweis
Wir übergeben dem ersten Aufruf von next()
keinen Wert, und das ist Absicht. Nur ein pausiertes yield
könnte einen solchen Wert akzeptieren, der von einemnext(..)
übergeben wird, und zu Beginn des Generators, wenn wir den erstennext()
aufrufen, gibt es kein pausiertes yield
, das einen solchen Wert akzeptieren könnte. Die Spezifikation und alle kompatiblen Browser verwerfen einfach alles, was dem erstennext()
übergeben wird. Es ist trotzdem keine gute Idee, einen Wert zu übergeben, da du damit nur verwirrenden, fehlschlagenden Code erzeugst. Beginne daher einen Generator immer mit einem argumentfreien next()
.
Der erste Aufruf von next()
(ohne Übergabe) stellt im Grunde eine Frage: "Welchen nächsten Wert muss mir der *foo(..)
Generator geben?" Und wer beantwortet diese Frage? Der erste yield "hello"
Ausdruck.
Siehst du? Da gibt es keine Unstimmigkeiten.
Je nachdem, an wen du denkst, wenn du die Frage stellst, gibt es entweder eine Unstimmigkeit zwischen den Aufrufen von yield
und next(..)
oder nicht.
Aber halt! Es gibt immer noch eine zusätzliche next()
im Vergleich zur Anzahl deryield
Anweisungen. Der letzte Aufruf von it.next(7)
stellt also wieder die Frage, welchen Wert der Generator als Nächstes erzeugen wird. Aber es gibt keine weiteren yield
Anweisungen mehr, die man beantworten könnte, oder? Wer antwortet also?
Die Aussage von return
beantwortet die Frage!
Und wenn es in deinem Generator kein return
gibt -return
ist in Generatoren sicherlich nicht notwendiger als in normalen Funktionen - gibt es immer ein angenommenes/implizites return;
(auch bekannt als return undefined;
), das dazu dient, die durch den letzten it.next(7)
-Aufruf gestellte Frage standardmäßig zu beantworten.
Diese Fragen und Antworten - die bidirektionale Nachrichtenübermittlung mit yield
und next(..)
- sind ziemlich mächtig, aber es ist überhaupt nicht klar, wie diese Mechanismen mit der asynchronen Flusskontrolle zusammenhängen. Wir kommen der Sache schon näher!
Mehrere Iteratoren
Wenn du einen Iteratorzur Steuerung eines Generators verwendest, scheint es so, als würdest du die deklarierte Generatorfunktion selbst steuern. Aber es gibt eine Feinheit, die leicht zu übersehen ist: Jedes Mal, wenn du einen Iterator konstruierst, konstruierst du implizit auch eine Instanz des Generators, den dieser Iterator steuern wird.
Du kannst mehrere Instanzen desselben Generators gleichzeitig laufen lassen, und sie können sogar miteinander interagieren:
function
*
foo
()
{
var
x
=
yield
2
;
z
++
;
var
y
=
yield
(
x
*
z
);
console
.
log
(
x
,
y
,
z
);
}
var
z
=
1
;
var
it1
=
foo
();
var
it2
=
foo
();
var
val1
=
it1
.
next
().
value
;
// 2 <-- yield 2
var
val2
=
it2
.
next
().
value
;
// 2 <-- yield 2
val1
=
it1
.
next
(
val2
*
10
).
value
;
// 40 <-- x:20, z:2
val2
=
it2
.
next
(
val1
*
5
).
value
;
// 600 <-- x:200, z:3
it1
.
next
(
val2
/
2
);
// y:300
// 20 300 3
it2
.
next
(
val1
/
4
);
// y:10
// 200 10 3
Warnung
Der häufigste Fall, in dem mehrere Instanzen desselben Generators gleichzeitig laufen, sind nicht solche Interaktionen, sondern wenn der Generator seine eigenen Werte ohne Input produziert, vielleicht von einer unabhängig verbundenen Ressource. Wir werden im nächsten Abschnitt mehr über die Werteproduktion sprechen.
Lass uns kurz durch die Verarbeitung gehen:
-
Beide Instanzen von
*foo()
werden zur gleichen Zeit gestartet, und beide Aufrufe vonnext()
offenbaren einvalue
von2
aus denyield 2
Anweisungen. -
val2 * 10
ist2 * 10
, das in die erste Generatorinstanzit1
gesendet wird, so dassx
den Wert20
erhält.z
wird von1
auf2
inkrementiert, und dann wird20 * 2
yield
herausgegeben, wodurchval1
auf40
gesetzt wird. -
val1 * 5
ist40 * 5
, das in die zweite Generatorinstanzit2
gesendet wird, so dassx
den Wert200
erhält.z
wird wieder inkrementiert, von2
auf3
, und dann wird200 * 3
yield
ausgegeben, wodurchval2
auf600
gesetzt wird. -
val2 / 2
ist600 / 2
, der in die erste Generatorinstanzit1
gesendet wird, so dassy
den Wert300
erhält, um dann20 300 3
für seinex y z
Werte auszudrucken. -
val1 / 4
ist40 / 4
, der in die zweite Generatorinstanzit2
gesendet wird, so dassy
den Wert10
erhält, um dann200 10 3
für seinex y z
Werte auszudrucken.
Das ist ein lustiges Beispiel, das du in deinem Kopf durchgehen kannst. Hast du es richtig verstanden?
Interleaving
Erinnere dich an dieses Szenario aus dem Abschnitt "Run-to-Completion" in Kapitel 1:
var
a
=
1
;
var
b
=
2
;
function
foo
()
{
a
++
;
b
=
b
*
a
;
a
=
b
+
3
;
}
function
bar
()
{
b
--
;
a
=
8
+
b
;
b
=
a
*
2
;
}
Bei normalen JS-Funktionen kann natürlich entweder foo()
komplett zuerst laufen oder bar()
komplett zuerst, aber foo()
kann seine einzelnen Anweisungen nicht mit bar()
verschachteln. Es gibt also nur zwei mögliche Ergebnisse für das vorangegangene Programm.
Mit Generatoren ist jedoch eindeutig eine Verschachtelung (sogar mitten in den Anweisungen!) möglich:
var
a
=
1
;
var
b
=
2
;
function
*
foo
()
{
a
++
;
yield
;
b
=
b
*
a
;
a
=
(
yield
b
)
+
3
;
}
function
*
bar
()
{
b
--
;
yield
;
a
=
(
yield
8
)
+
b
;
b
=
a
*
(
yield
2
);
}
Je nachdem, in welcher Reihenfolge die Iteratoren, die *foo()
und *bar()
steuern, aufgerufen werden, könnte das obige Programm verschiedene Ergebnisse liefern. Mit anderen Worten: Wir können die in Kapitel 1 besprochenen theoretischen Wettlaufbedingungen (auf eine Art Fake-Weise) tatsächlich veranschaulichen, indem wir die beiden Generator-Interaktionen über dieselben gemeinsamen Variablen verschachteln.
Als Erstes erstellen wir eine Hilfsfunktion namens step(..)
, die einenIterator steuert:
function
step
(
gen
)
{
var
it
=
gen
();
var
last
;
return
function
()
{
// whatever is `yield`ed out, just
// send it right back in the next time!
last
=
it
.
next
(
last
).
value
;
};
}
step(..)
initialisiert einen Generator, um seinen it
Iterator zu erstellen, und gibt dann eine Funktion zurück, die, wenn sie aufgerufen wird, den Iterator um einen Schritt weiterschaltet. Außerdem wird der zuvor yield
ausgegebene Wert beim nächsten Schritt direkt wieder zurückgeschickt. So wird aus yield 8
einfach 8
und ausyield b
einfach b
(was auch immer es zum Zeitpunkt von yield
war).
Jetzt wollen wir einfach mal ausprobieren, wie es sich auswirkt, wenn wir diese verschiedenen Teile von *foo()
und *bar()
miteinander verschachteln. Wir beginnen mit dem langweiligen Basisfall und stellen sicher, dass *foo()
vollständig vor *bar()
endet (genau wie in Kapitel 1):
// make sure to reset `a` and `b`
a
=
1
;
b
=
2
;
var
s1
=
step
(
foo
);
var
s2
=
step
(
bar
);
// run `*foo()` completely first
s1
();
s1
();
s1
();
// now run `*bar()`
s2
();
s2
();
s2
();
s2
();
console
.
log
(
a
,
b
);
// 11 22
Das Endergebnis ist 11
und 22
, genau wie in der Version aus Kapitel 1. Jetzt wollen wir die Reihenfolge der Verschachtelung ändern und sehen, wie sich die Endwerte von a
und b
ändern:
// make sure to reset `a` and `b`
a
=
1
;
b
=
2
;
var
s1
=
step
(
foo
);
var
s2
=
step
(
bar
);
s2
();
// b--;
s2
();
// yield 8
s1
();
// a++;
s2
();
// a = 8 + b;
// yield 2
s1
();
// b = b * a;
// yield b
s1
();
// a = b + 3;
s2
();
// b = a * 2;
Bevor ich dir die Ergebnisse verrate, kannst du herausfinden, was a
und b
nach dem vorangegangenen Programm sind? Nicht schummeln!
console
.
log
(
a
,
b
);
// 12 18
Hinweis
Als Übung für den Leser kannst du versuchen, wie viele andere Kombinationen von Ergebnissen du zurückbekommst, wenn du die Reihenfolge der Aufrufe vons1()
und s2()
umstellst. Vergiss nicht, dass du immer drei Aufrufe von s1()
und vier Aufrufe von s2()
brauchst. Erinnere dich an die Diskussion über den Abgleich von next()
mit yield
, um die Gründe dafür zu erfahren.
Du wirst mit Sicherheit nicht absichtlich ein derartiges Durcheinander erzeugen wollen, denn das macht den Code unglaublich schwer verständlich. Aber die Übung ist interessant und lehrreich, um mehr darüber zu erfahren, wie mehrere Generatoren gleichzeitig im selben gemeinsamen Bereich laufen können, denn es wird Stellen geben, an denen diese Fähigkeit sehr nützlich ist.
Wir werden die Gleichzeitigkeit von Generatoren im Abschnitt "Gleichzeitigkeit von Generatoren" ausführlicher besprechen .
Generatorische Werte
Im vorigen Abschnitt haben wir einen interessanten Anwendungsfall für Generatoren erwähnt, nämlich die Erzeugung von Werten. Das ist zwar nicht das Hauptthema dieses Kapitels, aber wir wären nachlässig, wenn wir nicht auf die Grundlagen eingehen würden, vor allem, weil dieser Anwendungsfall im Grunde der Ursprung des Namens ist: Generatoren.
Wir werden jetzt einen kleinen Abstecher zum Thema Iteratoren machen, aber wir werden darauf zurückkommen, wie sie mit Generatoren und der Verwendung eines Generators zur Erzeugung von Werten zusammenhängen.
Erzeuger und Iteratoren
Stell dir vor, du produzierst eine Reihe von Werten, wobei jeder Wert eine definierbare Beziehung zum vorherigen Wert hat. Dazu brauchst du einen zustandsbehafteten Produzenten, der sich an den letzten Wert erinnert, den er ausgegeben hat.
Du kannst so etwas ganz einfach mit einer Funktionsschließung implementieren (siehe den Titel Scope & Closures in dieser Serie):
var
gimmeSomething
=
(
function
(){
var
nextVal
;
return
function
(){
if
(
nextVal
===
undefined
)
{
nextVal
=
1
;
}
else
{
nextVal
=
(
3
*
nextVal
)
+
6
;
}
return
nextVal
;
};
})();
gimmeSomething
();
// 1
gimmeSomething
();
// 9
gimmeSomething
();
// 33
gimmeSomething
();
// 105
Hinweis
Die Berechnungslogik von nextVal
hätte vereinfacht werden können, aber konzeptionell wollen wir den nächsten Wert (akanextVal
) erst nach dem nächsten Aufruf von gimmeSomething()
berechnen, weil das im Allgemeinen ein ressourcenschwaches Design für Produzenten von dauerhafteren oder ressourcenbegrenzten Werten als einfachen number
s sein könnte.
Eine beliebige Zahlenreihe zu generieren ist kein besonders realistisches Beispiel. Aber was wäre, wenn du Datensätze aus einer Datenquelle generieren würdest? Du könntest dir den gleichen Code vorstellen.
Tatsächlich ist diese Aufgabe ein sehr verbreitetes Entwurfsmuster, das in der Regel durch Iteratoren gelöst wird. Ein Iterator ist eine klar definierte Schnittstelle, mit der du durch eine Reihe von Werten eines Produzenten schreiten kannst. Die JS-Schnittstelle für Iteratoren besteht, wie in den meisten Sprachen, darin, next()
jedes Mal aufzurufen, wenn du den nächsten Wert vom Produzenten benötigst.
Wir könnten die Standard-Iterator-Schnittstelle für unseren Zahlenreihenproduzenten implementieren:
var
something
=
(
function
(){
var
nextVal
;
return
{
// needed for `for..of` loops
[
Symbol
.
iterator
]
:
function
(){
return
this
;
},
// standard iterator interface method
next
:
function
(){
if
(
nextVal
===
undefined
)
{
nextVal
=
1
;
}
else
{
nextVal
=
(
3
*
nextVal
)
+
6
;
}
return
{
done
:
false
,
value
:
nextVal
};
}
};
})();
something
.
next
().
value
;
// 1
something
.
next
().
value
;
// 9
something
.
next
().
value
;
// 33
something
.
next
().
value
;
// 105
Hinweis
Warum wir den [Symbol.iterator]: ..
Teil dieses Codeschnipsels brauchen, werden wir in "Iterables" erklären . Syntaktisch gesehen sind jedoch zwei ES6-Funktionen im Spiel. Erstens wird die Syntax von [ .. ]
als "computed property name" bezeichnet (siehe den Titel " this & Object Prototypes " in dieser Serie). Es ist eine Möglichkeit, in einer Objektliteraldefinition einen Ausdruck anzugeben und das Ergebnis dieses Ausdrucks als Eigenschaftsnamen zu verwenden. Symbol.iterator
ist einer der vordefinierten speziellenSymbol
Werte von ES6 (siehe den Titel ES6 & Beyond dieser Serie).
Der Aufruf next()
gibt ein Objekt mit zwei Eigenschaften zurück: done
ist einboolean
Wert, der den vollständigen Status des Iteratorssignalisiert; value
enthält den Iterationswert.
ES6 fügt außerdem die for..of
Schleife hinzu, was bedeutet, dass ein Standard-Iteratorautomatisch mit nativer Schleifensyntax konsumiert werden kann:
for
(
var
v
of
something
)
{
console
.
log
(
v
);
// don't let the loop run forever!
if
(
v
>
500
)
{
break
;
}
}
// 1 9 33 105 321 969
Hinweis
Da unser something
Iterator immer done:false
zurückgibt, würde diese for..of
Schleife ewig laufen, weshalb wir die break
Bedingung eingebaut haben. Es ist völlig in Ordnung, wenn Iteratoren nie enden, aber es gibt auch Fälle, in denen der Iterator über eine endliche Menge von Werten läuft und schließlich ein done:true
zurückgibt.
Die Schleife for..of
ruft bei jeder Iteration automatisch next()
auf - sie übergibt keine Werte an next()
- und wird automatisch beendet, wenn sie ein done:true
erhält. Sie ist sehr praktisch, um eine Schleife über eine Reihe von Daten zu ziehen.
Natürlich könntest du auch manuell eine Schleife über die Iteratoren ziehen, next()
aufrufen und auf die Bedingung done:true
prüfen, um zu wissen, wann du aufhören musst:
for
(
var
ret
;
(
ret
=
something
.
next
())
&&
!
ret
.
done
;
)
{
console
.
log
(
ret
.
value
);
// don't let the loop run forever!
if
(
ret
.
value
>
500
)
{
break
;
}
}
// 1 9 33 105 321 969
Hinweis
Dieser manuelle for
Ansatz ist sicherlich hässlicher als die ES6for..of
Schleifensyntax, aber sein Vorteil ist, dass er dir die Möglichkeit gibt, Werte an die next(..)
Aufrufe zu übergeben, falls nötig.
Du kannst nicht nur eigene Iteratoren erstellen, sondern viele eingebaute Datenstrukturen in JS (ab ES6), wie array
, haben auchStandard-Iteratoren:
var
a
=
[
1
,
3
,
5
,
7
,
9
];
for
(
var
v
of
a
)
{
console
.
log
(
v
);
}
// 1 3 5 7 9
Die Schleife for..of
fragt a
nach seinem Iterator und benutzt ihn automatisch, um über die Werte von a
zu iterieren.
Hinweis
Es mag seltsam erscheinen, dass ES6 das nicht tut, aber reguläre object
werden absichtlich nicht mit einem Standard-Iterator geliefert, so wie array
. Die Gründe dafür gehen tiefer, als wir hier behandeln werden. Wenn du nur über die Eigenschaften eines Objekts iterieren willst (ohne besondere Garantie für die Reihenfolge), gibt Object.keys(..)
einen array
zurück, der dann wie for (var k of Object.keys(obj)) { ..
verwendet werden kann. Eine solche for..of
-Schleife über die Schlüssel eines Objekts wäre ähnlich wie eine for..in
-Schleife, mit dem Unterschied, dass Object.keys(..)
die Eigenschaften aus der [[Prototype]]
-Kette nicht mit einbezieht, während for..in
dies tut (siehe den Titel this & Object Prototypes dieser Serie).
Iterables
Das Objekt something
in unserem Beispiel wird als Iterator bezeichnet, da es die Methode next()
in seiner Schnittstelle hat. Ein eng verwandter Begriff ist iterable, d.h. ein object
, das einen Iteratorenthält, der über seine Werte iterieren kann.
Seit ES6 kann man einen Iterator aus einer Iterable abrufen, indem man eine Funktion in der Iterable aufruft, deren Name der spezielle ES6-Symbolwert Symbol.iterator
ist. Wenn diese Funktion aufgerufen wird, gibt sie einen Iterator zurück. Obwohl dies nicht erforderlich ist, sollte in der Regel bei jedem Aufruf ein neuer Iterator zurückgegeben werden.
a
im vorherigen Schnipsel ist eine Iterable. Die for..of
Schleife ruft automatisch die Funktion Symbol.iterator
auf, um einenIterator zu erstellen. Aber wir können die Funktion natürlich auch manuell aufrufen und den Iterator verwenden, den sie zurückgibt:
var
a
=
[
1
,
3
,
5
,
7
,
9
];
var
it
=
a
[
Symbol
.
iterator
]();
it
.
next
().
value
;
// 1
it
.
next
().
value
;
// 3
it
.
next
().
value
;
// 5
..
In der vorherigen Codeauflistung, die something
definiert, ist dir vielleicht diese Zeile aufgefallen:
[
Symbol
.
iterator
]
:
function
(){
return
this
;
}
Dieses kleine Stück verwirrenden Codes macht den Wert something
- die Schnittstelle des Iterators something
- auchzu einer Iterable; er ist jetzt sowohl eine Iterable als auch ein Iterator. Dann übergeben wir something
an diefor..of
Schleife:
for
(
var
v
of
something
)
{
..
}
Die Schleife for..of
erwartet, dass something
eine Iterable ist, also sucht sie die Funktion Symbol.iterator
und ruft sie auf. Wir haben diese Funktion einfach als return this
definiert, so dass sie sich selbst zurückgibt und die for..of
Schleife nichts davon mitbekommt.
Generator Iterator
Wenden wir uns nun wieder den Generatoren zu, und zwar im Zusammenhang mitIteratoren. Ein Generator kann als Produzent von Werten betrachtet werden, die wir über die next()
Aufrufe der Iteratorschnittstelle nacheinander extrahieren.
Ein Generator selbst ist also technisch gesehen keine Iterable, obwohl er sehr ähnlich ist - wenn du den Generator ausführst, bekommst du einen Iteratorzurück:
function
*
foo
(){
..
}
var
it
=
foo
();
Wir können den Erzeuger der unendlichen Zahlenreihe something
von vorhin mit einem Generator umsetzen, etwa so:
function
*
something
()
{
var
nextVal
;
while
(
true
)
{
if
(
nextVal
===
undefined
)
{
nextVal
=
1
;
}
else
{
nextVal
=
(
3
*
nextVal
)
+
6
;
}
yield
nextVal
;
}
}
Hinweis
Eine while..true
-Schleife wäre in einem echten JS-Programm normalerweise eine sehr schlechte Sache, zumindest wenn sie kein break
oderreturn
enthält, da sie wahrscheinlich ewig und synchron laufen und die Benutzeroberfläche des Browsers blockieren/verriegeln würde. In einem Generator ist eine solche Schleife jedoch in der Regel völlig in Ordnung, wenn sie ein yield
enthält, da der Generator bei jeder Iteration eine Pause macht und yield
zurück zum Hauptprogramm und/oder zur Warteschlange der Ereignisschleife sendet. Um es kurz zu machen: "Generatoren bringen diewhile..true
zurück in die JS-Programmierung!"
Das ist doch viel sauberer und einfacher, oder? Da der Generator bei jedem yield
eine Pause einlegt, bleibt der Zustand (Scope) der Funktion *something()
erhalten, d.h. es ist nicht notwendig, dass die Closure-Boilerplate den Zustand der Variablen über mehrere Aufrufe hinweg beibehält.
Der Code ist nicht nur einfacher - wir müssen keine eigene Iteratorschnittstelleerstellen -, sondern er ist auch vernünftiger, weil er die Absicht klarer zum Ausdruck bringt. Die Schleife while..true
sagt uns zum Beispiel, dass der Generator für immer laufen soll - er soll so lange Werte erzeugen, wie wir sie abfragen.
Und jetzt können wir unseren neuen *something()
Generator mit einerfor..of
Schleife verwenden, und du wirst sehen, dass er im Grunde genauso funktioniert:
for
(
var
v
of
something
())
{
console
.
log
(
v
);
// don't let the loop run forever!
if
(
v
>
500
)
{
break
;
}
}
// 1 9 33 105 321 969
Aber überspringe nicht for (var v of something()) ..
! Wir haben nicht einfach something
als Wert referenziert, wie in früheren Beispielen, sondern den *something()
Generator aufgerufen, um seinen Iterator für diefor..of
Schleife zu erhalten.
Wenn du genau hinschaust, ergeben sich aus dieser Interaktion zwischen dem Generator und der Schleife vielleicht zwei Fragen:
-
Warum können wir nicht
for (var v of something) ..
sagen? Weilsomething
hier ein Generator ist, der nicht iterierbar ist. Wir müssensomething()
aufrufen, um einen Erzeuger zu konstruieren, über den diefor..of
Schleife iterieren kann. -
Der Aufruf von
something()
erzeugt einen Iterator, aber diefor..of
Schleife will eine Iterable, richtig? Ja, genau. Der Iterator des Generators hat auch eineSymbol.iterator
Funktion, die im Grunde genommen einereturn this
Funktion ist, genau wie diesomething
Iterable, die wir zuvor definiert haben. Mit anderen Worten: Der Iterator eines Generators ist auch eine Iterable!
Anhalten des Generators
Im vorherigen Beispiel scheint es so, als ob die Iterator-Instanz für den*something()
-Generator nach dem Aufruf von break
in der Schleife für immer in einem Schwebezustand belassen wurde.
Aber es gibt ein verstecktes Verhalten, das dir das abnimmt. Ein "abnormaler Abschluss" (d.h. ein "vorzeitiger Abbruch") der for..of
Schleife - in der Regel verursacht durch eine break
, return
oder eine nicht abgefangene Ausnahme - sendet ein Signal an den Iterator des Generators, damit dieser abbricht.
Hinweis
Technisch gesehen sendet die Schleife for..of
dieses Signal auch an denIterator, wenn die Schleife normal beendet wird. Für einen Generator ist das im Grunde genommen überflüssig, da der Iterator des Generators zuerst fertig sein muss, damit die for..of
Schleife abgeschlossen werden kann. BenutzerdefinierteIteratoren möchten jedoch möglicherweise dieses zusätzliche Signal von den Verbrauchern derfor..of
Schleife erhalten.
Während eine for..of
-Schleife dieses Signal automatisch sendet, kannst du das Signal auch manuell an einen Iterator senden; das machst du, indem dureturn(..)
aufrufst.
Wenn du eine try..finally
Klausel innerhalb des Generators angibst, wird sie immer ausgeführt, auch wenn der Generator extern beendet wird. Das ist nützlich, wenn du Ressourcen aufräumen musst (Datenbankverbindungen usw.):
function
*
something
()
{
try
{
var
nextVal
;
while
(
true
)
{
if
(
nextVal
===
undefined
)
{
nextVal
=
1
;
}
else
{
nextVal
=
(
3
*
nextVal
)
+
6
;
}
yield
nextVal
;
}
}
// cleanup clause
finally
{
console
.
log
(
"cleaning up!"
);
}
}
Das frühere Beispiel mit break
in der for..of
Schleife wird diefinally
Klausel auslösen. Du könntest aber stattdessen die Iterator-Instanz des Generators manuell von außen mit return(..)
beenden:
var
it
=
something
();
for
(
var
v
of
it
)
{
console
.
log
(
v
);
// don't let the loop run forever!
if
(
v
>
500
)
{
console
.
log
(
// complete the generator's iterator
it
.
return
(
"Hello World"
).
value
);
// no `break` needed here
}
}
// 1 9 33 105 321 969
// cleaning up!
// Hello World
Wenn wir it.return(..)
aufrufen, wird der Generator sofort beendet, der natürlich die finally
Klausel ausführt. Außerdem wird die zurückgegebenevalue
auf den Wert gesetzt, den du an return(..)
übergeben hast, so dass"Hello World"
direkt wieder herauskommt. Außerdem müssen wir jetzt keinebreak
mehr einfügen, weil der Iterator des Generators auf done:true
gesetzt ist, so dass die for..of
Schleife bei ihrer nächsten Iteration beendet wird.
Generatoren verdanken ihren Namen vor allem dieser Verwendung von konsumierten Werten. Aber auch das ist nur eine der Verwendungen von Generatoren, und ehrlich gesagt nicht einmal die wichtigste, mit der wir uns in diesem Buch beschäftigen.
Nachdem wir nun die Funktionsweise der Generatoren besser verstanden haben, können wir uns nun der Frage zuwenden, wie Generatoren auf die asynchrone Gleichzeitigkeit angewendet werden.
Asynchrone Iteration von Generatoren
Was haben Generatoren mit asynchronen Codierungsmustern, der Behebung von Problemen mit Callbacks und Ähnlichem zu tun? Lass uns diese wichtige Frage beantworten.
Wir sollten eines unserer Szenarien aus Kapitel 3 noch einmal durchgehen. Erinnern wir uns an den Callback-Ansatz:
function
foo
(
x
,
y
,
cb
)
{
ajax
(
"http://some.url.1/?x="
+
x
+
"&y="
+
y
,
cb
);
}
foo
(
11
,
31
,
function
(
err
,
text
)
{
if
(
err
)
{
console
.
error
(
err
);
}
else
{
console
.
log
(
text
);
}
}
);
Wenn wir die gleiche Aufgabenablaufsteuerung mit einem Generator ausdrücken wollten, könnten wir das tun:
function
foo
(
x
,
y
)
{
ajax
(
"http://some.url.1/?x="
+
x
+
"&y="
+
y
,
function
(
err
,
data
){
if
(
err
)
{
// throw an error into `*main()`
it
.
throw
(
err
);
}
else
{
// resume `*main()` with received `data`
it
.
next
(
data
);
}
}
);
}
function
*
main
()
{
try
{
var
text
=
yield
foo
(
11
,
31
);
console
.
log
(
text
);
}
catch
(
err
)
{
console
.
error
(
err
);
}
}
var
it
=
main
();
// start it all up!
it
.
next
();
Auf den ersten Blick ist dieses Snippet länger und sieht vielleicht etwas komplexer aus als das Callback-Snippet vor ihm. Aber lass dich von diesem Eindruck nicht beirren. Das Generator-Snippet ist tatsächlich vielbesser! Aber es gibt eine Menge, das wir erklären müssen.
Schauen wir uns zunächst diesen Teil des Codes an, der der wichtigste ist:
var
text
=
yield
foo
(
11
,
31
);
console
.
log
(
text
);
Denke einen Moment darüber nach, wie dieser Code funktioniert. Wir rufen eine normale Funktion foo(..)
auf und sind offensichtlich in der Lage, die text
vom Ajax-Aufruf zurückzubekommen, obwohl dieser asynchron ist.
Wie ist das möglich? Wenn du dich an den Anfang von Kapitel 1 erinnerst, hatten wir fast den gleichen Code:
var
data
=
ajax
(
"..url 1.."
);
console
.
log
(
data
);
Und dieser Code hat nicht funktioniert! Kannst du den Unterschied erkennen? Es ist die yield
, die in einem Generator verwendet wird.
Das ist die Magie! So können wir scheinbar blockierenden, synchronen Code haben, der aber nicht das ganze Programm blockiert, sondern nur den Code im Generator selbst.
In yield foo(11,31)
wird zuerst der Aufruf foo(11,31)
gemacht, der nichts zurückgibt (auch bekannt als undefined
), also machen wir einen Aufruf, um Daten anzufordern, aber eigentlich machen wir dann yield undefined
. Das ist in Ordnung, denn der Code verlässt sich momentan nicht auf einen yield
Wert, um etwas Interessantes zu tun. Wir werden diesen Punkt später im Kapitel noch einmal aufgreifen.
Wir verwenden yield
hier nicht im Sinne einer Nachrichtenübermittlung, sondern nur im Sinne einer Flusskontrolle, um zu pausieren/blockieren. Tatsächlich werden Nachrichten weitergegeben, aber nur in eine Richtung, nachdem der Generator wieder gestartet wurde.
Der Generator hält also bei yield
an und stellt die Frage: "Welchen Wert soll ich zurückgeben, um ihn der Variablen text
zuzuweisen?" Wer soll diese Frage beantworten?
Schau dir foo(..)
an. Wenn die Ajax-Anfrage erfolgreich ist, rufen wir auf:
it
.
next
(
data
);
Das bedeutet, dass unser angehaltener Ausdruck yield
diesen Wert direkt erhält und dass dieser Wert beim Neustart des Generatorcodes der lokalen Variablen text
zugewiesen wird.
Ziemlich cool, oder?
Tritt einen Schritt zurück und überlege, was das bedeutet. Der Code im Generator sieht völlig synchron aus (abgesehen vom Schlüsselwort yield
selbst), aber hinter den Kulissen von foo(..)
können die Vorgänge asynchron ausgeführt werden.
Das ist großartig! Das ist eine fast perfekte Lösung für unser Problem, dass Callbacks nicht in der Lage sind, Asynchronität auf eine sequenzielle, synchrone Weise auszudrücken, mit der unser Gehirn etwas anfangen kann.
Im Grunde genommen abstrahieren wir die Asynchronität als Implementierungsdetail, damit wir synchron/sequenziell über unsere Ablaufsteuerung nachdenken können: "Stelle eine Ajax-Anfrage und drucke die Antwort aus, wenn sie fertig ist." Natürlich haben wir nur zwei Schritte in der Ablaufsteuerung ausgedrückt, aber diese Fähigkeit lässt sich unbegrenzt erweitern, so dass wir beliebig viele Schritte ausdrücken können.
Tipp
Das ist eine so wichtige Erkenntnis, dass du die letzten drei Absätze noch einmal lesen solltest, damit du sie verinnerlichen kannst!
Synchrone Fehlerbehandlung
Aber der vorhergehende Generatorcode hat noch mehr Gutes für uns zu bieten. Richten wir unsere Aufmerksamkeit auf die try..catch
innerhalb des Generators:
try
{
var
text
=
yield
foo
(
11
,
31
);
console
.
log
(
text
);
}
catch
(
err
)
{
console
.
error
(
err
);
}
Wie funktioniert das? Der Aufruf von foo(..)
wird asynchron ausgeführt, und schlägt try..catch
nicht fehl, um asynchrone Fehler abzufangen, wie wir in Kapitel 3 gesehen haben?
Wir haben bereits gesehen, wie yield
die Zuweisungsanweisung pausieren lässt, um zu warten, bis foo(..)
fertig ist, damit die fertige Antwort text
zugewiesen werden kann. Das Tolle daran ist, dass dieses yield
Pausieren dem Generator aucherlaubt, catch
einen Fehler zu melden. Diesen Fehler geben wir mit diesem Teil des früheren Code-Listings an den Generator weiter:
if
(
err
)
{
// throw an error into `*main()`
it
.
throw
(
err
);
}
Die yield
-pause Natur von Generatoren bedeutet, dass wir nicht nur synchron aussehende return
Werte von asynchronen Funktionsaufrufen erhalten, sondern auch synchron catch
Fehler von diesen asynchronen Funktionsaufrufen!
Wir haben also gesehen, dass wir Fehler in einen Generator hineinwerfen können, aber was ist mit Fehlern, die aus einem Generator herauskommen? Genau so, wie du es erwartet hast:
function
*
main
()
{
var
x
=
yield
"Hello World"
;
yield
x
.
toLowerCase
();
// cause an exception!
}
var
it
=
main
();
it
.
next
().
value
;
// Hello World
try
{
it
.
next
(
42
);
}
catch
(
err
)
{
console
.
error
(
err
);
// TypeError
}
Natürlich hätten wir auch manuell einen Fehler mit throw ..
auslösen können, anstatt eine Ausnahme zu machen.
Wir können sogar catch
denselben Fehler, den wir throw(..)
in den Generator eingeben, so dass der Generator die Chance hat, den Fehler zu behandeln, aber wenn er es nicht tut, muss der Iterator-Code ihn behandeln:
function
*
main
()
{
var
x
=
yield
"Hello World"
;
// never gets here
console
.
log
(
x
);
}
var
it
=
main
();
it
.
next
();
try
{
// will `*main()` handle this error? we'll see!
it
.
throw
(
"Oops"
);
}
catch
(
err
)
{
// nope, didn't handle it!
console
.
error
(
err
);
// Oops
}
Die synchrone Fehlerbehandlung (über try..catch
) mit asynchronem Code ist ein großer Gewinn für die Lesbarkeit und Verständlichkeit.
Generatoren + Versprechen
In unserer vorherigen Diskussion haben wir gezeigt, wie Generatoren asynchron iteriert werden können. Das ist ein großer Fortschritt in der sequentiellen Vernunft gegenüber dem Spaghetti-Wirrwarr der Callbacks. Aber wir haben etwas sehr Wichtiges verloren: die Vertrauenswürdigkeit und Zusammensetzbarkeit von Promises (siehe Kapitel 3)!
Keine Sorge - das können wir zurückbekommen. Das Beste aus allen Welten in ES6 ist die Kombination von Generatoren (synchron aussehender asynchroner Code) mit Promises (vertrauenswürdig und zusammensetzbar).
Aber wie?
Erinnere dich an den Promise-basierten Ansatz aus Kapitel 3 für unser laufendes Ajax-Beispiel:
function
foo
(
x
,
y
)
{
return
request
(
"http://some.url.1/?x="
+
x
+
"&y="
+
y
);
}
foo
(
11
,
31
)
.
then
(
function
(
text
){
console
.
log
(
text
);
},
function
(
err
){
console
.
error
(
err
);
}
);
In unserem früheren Generatorcode für das laufende Ajax-Beispiel gab foo(..)
nichts zurück (undefined
), und unser Iterator-Steuerungscode kümmerte sich nicht um diesen yield
ed-Wert.
Aber hier gibt die Promise-aware foo(..)
ein Versprechen zurück, nachdem sie den Ajax-Aufruf gemacht hat. Das legt nahe, dass wir mitfoo(..)
ein Versprechen konstruieren und es dann mit yield
vom Generator abrufen könnten, damit derIterator-Kontrollcode dieses Versprechen erhält.
Aber was soll der Iterator mit dem Versprechen machen?
Er sollte darauf warten, dass das Versprechen aufgelöst wird (Erfüllung oder Ablehnung) und dann entweder den Generator mit der Erfüllungsmeldung fortsetzen oder einen Fehler mit dem Ablehnungsgrund in den Generator werfen.
Lass mich das wiederholen, weil es so wichtig ist. Der natürliche Weg, um das Beste aus Promises und Generatoren herauszuholen, ist yield
eine Promise und die Verdrahtung dieser Promise, um den Iterator des Generators zu steuern.
Lass es uns ausprobieren! Zuerst setzen wir den Promise-aware foo(..)
mit dem Generator *main()
zusammen:
function
foo
(
x
,
y
)
{
return
request
(
"http://some.url.1/?x="
+
x
+
"&y="
+
y
);
}
function
*
main
()
{
try
{
var
text
=
yield
foo
(
11
,
31
);
console
.
log
(
text
);
}
catch
(
err
)
{
console
.
error
(
err
);
}
}
Die wichtigste Erkenntnis dieses Refactors ist, dass der Code in*main()
überhaupt nicht geändert werden musste! Innerhalb des Generators sind die Werte, die yield
ausgibt, nur ein undurchsichtiges Implementierungsdetail, so dass wir nicht einmal wissen, dass es passiert, und uns auch nicht darum kümmern müssen.
Aber wie werden wir *main()
jetzt ausführen? Wir müssen noch etwas an der Implementierung arbeiten, um das yield
Versprechen zu empfangen und so zu verdrahten, dass es den Generator nach der Auflösung wieder aufnimmt. Wir beginnen damit, dies manuell zu versuchen:
var
it
=
main
();
var
p
=
it
.
next
().
value
;
// wait for the `p` promise to resolve
p
.
then
(
function
(
text
){
it
.
next
(
text
);
},
function
(
err
){
it
.
throw
(
err
);
}
);
Eigentlich war das doch gar nicht so schmerzhaft, oder?
Dieses Snippet sollte sehr ähnlich aussehen wie das, was wir zuvor mit dem manuell verdrahteten Generator gemacht haben, der durch den Error-First-Callback gesteuert wird. Anstelle eines if (err) { it.throw..
trennt das Versprechen die Erfüllung (Erfolg) und die Ablehnung (Misserfolg) bereits für uns auf, aber ansonsten ist die Iteratorsteuerungidentisch.
Jetzt haben wir ein paar wichtige Details übersehen.
Vor allem haben wir uns die Tatsache zunutze gemacht, dass wir wussten, dass*main()
nur einen Promise-fähigen Schritt in sich hat. Was wäre, wenn wir in der Lage sein wollten, einen Generator mit Versprechen zu steuern, egal wie viele Schritte er hat? Wir wollen sicher nicht für jeden Generator die Promise-Kette manuell anders schreiben! Viel schöner wäre es, wenn es eine Möglichkeit gäbe, die Iterationskontrolle zu wiederholen (auch bekannt als Schleife) und jedes Mal, wenn ein Versprechen herauskommt, auf seine Auflösung zu warten, bevor man fortfährt.
Was ist, wenn der Generator während des Aufrufs von it.next(..)
einen Fehler ausgibt (absichtlich oder versehentlich)? Sollen wir den Vorgang abbrechen oder sollen wir catch
aufrufen und ihn direkt zurücksenden? Und was ist, wenn wirit.throw(..)
eine Promise-Ablehnung in den Generator schicken, diese aber nicht verarbeitet wird und direkt wieder herauskommt?
Versprechungsbewusster Generator Läufer
Je mehr du anfängst, diesen Weg zu erforschen, desto mehr merkst du: "Wow, es wäre toll, wenn es einfach ein Hilfsmittel gäbe, das das für mich erledigt." Und du hast absolut Recht. Da es sich um ein so wichtiges Muster handelt und du es nicht falsch machen willst (oder dich damit abmühst, es immer und immer wieder zu wiederholen), ist es am besten, wenn du ein Dienstprogramm verwendest, das speziell dafür entwickelt wurde, Versprechenyield
ing-Generatoren so auszuführen, wie wir es hier gezeigt haben.
Mehrere Promise-Abstraktionsbibliotheken bieten ein solches Hilfsmittel, darunter meine Asynquence-Bibliothek und ihre runner(..)
, die in Anhang A dieses Buches besprochen werden.
Aber zum Lernen und zur Veranschaulichung definieren wir einfach unser eigenes eigenständiges Dienstprogramm, das wir run(..)
nennen:
// thanks to Benjamin Gruenbaum (@benjamingr on GitHub) for
// big improvements here!
function
run
(
gen
)
{
var
args
=
[].
slice
.
call
(
arguments
,
1
),
it
;
// initialize the generator in the current context
it
=
gen
.
apply
(
this
,
args
);
// return a promise for the generator completing
return
Promise
.
resolve
()
.
then
(
function
handleNext
(
value
){
// run to the next yielded value
var
next
=
it
.
next
(
value
);
return
(
function
handleResult
(
next
){
// generator has completed running?
if
(
next
.
done
)
{
return
next
.
value
;
}
// otherwise keep going
else
{
return
Promise
.
resolve
(
next
.
value
)
.
then
(
// resume the async loop on
// success, sending the resolved
// value back into the generator
handleNext
,
// if `value` is a rejected
// promise, propagate error back
// into the generator for its own
// error handling
function
handleErr
(
err
)
{
return
Promise
.
resolve
(
it
.
throw
(
err
)
)
.
then
(
handleResult
);
}
);
}
})(
next
);
}
);
}
Wie du siehst, ist das ein bisschen komplexer, als du wahrscheinlich selbst schreiben möchtest, und du möchtest diesen Code nicht für jeden Generator wiederholen, den du verwendest. Daher ist ein Hilfsprogramm oder eine Bibliothek definitiv der richtige Weg. Trotzdem solltest du dir ein paar Minuten Zeit nehmen, um den Code zu studieren, damit du ein besseres Gefühl dafür bekommst, wie du die Verhandlungen zwischen Generator und Promise führen kannst.
Wie würdest du run(..)
mit *main()
in unserem laufenden Ajax-Beispiel verwenden?
function
*
main
()
{
// ..
}
run
(
main
);
Das war's! So wie wir run(..)
verdrahtet haben, wird es den Generator, den du ihm übergibst, automatisch und asynchron bis zur Fertigstellung vorantreiben.
Hinweis
Die run(..)
, die wir definiert haben, gibt ein Versprechen zurück, das aufgelöst wird, sobald der Generator fertig ist, oder eine nicht abgefangene Ausnahme erhält, wenn der Generator sie nicht behandelt. Wir zeigen diese Fähigkeit hier nicht, aber wir werden später in diesem Kapitel darauf zurückkommen.
ES7: async und await?
Das vorangegangene Muster - Generatoren yield
ing Promises, die dann den Iterator des Generators steuern, um ihn zur Vollendung zu bringen - ist ein so mächtiger und nützlicher Ansatz, dass es schöner wäre, wenn wir es ohne das Durcheinander der Hilfsprogramme der Bibliothek (auch bekannt als run(..)
) machen könnten.
Es gibt wahrscheinlich gute Nachrichten an dieser Front. Zum Zeitpunkt des Verfassens dieses Artikels gibt es erste, aber starke Unterstützung für einen Vorschlag für mehr syntaktische Ergänzungen in diesem Bereich für die Zeit nach ES6, etwa ab ES7. Natürlich ist es noch zu früh, um die Details zu garantieren, aber die Chancen stehen nicht schlecht, dass es in etwa so ablaufen wird wie im Folgenden beschrieben:
function
foo
(
x
,
y
)
{
return
request
(
"http://some.url.1/?x="
+
x
+
"&y="
+
y
);
}
async
function
main
()
{
try
{
var
text
=
await
foo
(
11
,
31
);
console
.
log
(
text
);
}
catch
(
err
)
{
console
.
error
(
err
);
}
}
main
();
Wie du siehst, gibt es keinen Aufruf von run(..)
(d.h. du brauchst kein Bibliotheksdienstprogramm!), um main()
aufzurufen und zu steuern - sie wird einfach als normale Funktion aufgerufen. Außerdem ist main()
nicht mehr als Generatorfunktion deklariert, sondern als eine neue Art von Funktion: async function
. Und stattyield
ein Promise aufzurufen, fordern wir await
auf, es aufzulösen.
async function
weiß automatisch, was zu tun ist, wenn du await
ein Versprechen gibst - es hält die Funktion an (genau wie bei Generatoren), bis das Versprechen aufgelöst wird. Wir haben es in diesem Snippet nicht gezeigt, aber der Aufruf einer asynchronen Funktion wie main()
gibt automatisch ein Versprechen zurück, das aufgelöst wird, sobald die Funktion vollständig beendet ist.
Tipp
Die Syntax von async
/ await
sollte Lesern mit Erfahrung in C# sehr vertraut vorkommen, da sie im Grunde genommen identisch ist.
Der Vorschlag kodiert im Wesentlichen die Unterstützung für das Muster, das wir bereits abgeleitet haben, in einem syntaktischen Mechanismus: die Kombination von Promises mit synchronem Flow Control Code. Das ist das Beste aus beiden Welten und löst praktisch alle wichtigen Probleme, die wir mit Rückrufen beschrieben haben.
Allein die Tatsache, dass es einen solchen ES7-ähnlichen Vorschlag bereits gibt und er bereits Unterstützung und Begeisterung findet, ist ein großer Vertrauensbeweis für die zukünftige Bedeutung dieses asynchronen Musters.
Gleichzeitigkeit von Versprechen in Generatoren
Bisher haben wir nur einen einstufigen asynchronen Ablauf mitPromises und Generatoren gezeigt. Aber in der Realität hat der Code oft viele asynchrone Schritte.
Wenn du nicht aufpasst, kann dich der synchrone Stil der Generatoren dazu verleiten, deine asynchrone Gleichzeitigkeit selbstgefällig zu strukturieren, was zu suboptimalen Leistungsmustern führt. Deshalb wollen wir uns ein wenig Zeit nehmen, um die Optionen zu erkunden.
Stell dir ein Szenario vor, in dem du Daten aus zwei verschiedenen Quellen abrufen, diese Antworten dann für eine dritte Anfrage kombinieren und schließlich die letzte Antwort ausdrucken musst. In Kapitel 3 haben wir ein ähnliches Szenario mit Promises erforscht, aber betrachten wir es noch einmal im Kontext von Generatoren.
Dein erster Instinkt ist vielleicht so etwas wie:
function
*
foo
()
{
var
r1
=
yield
request
(
"http://some.url.1"
);
var
r2
=
yield
request
(
"http://some.url.2"
);
var
r3
=
yield
request
(
"http://some.url.3/?v="
+
r1
+
","
+
r2
);
console
.
log
(
r3
);
}
// use previously defined `run(..)` utility
run
(
foo
);
Dieser Code funktioniert zwar, aber in unserem Fall ist er nicht optimal. Kannst du erkennen, warum?
Die Anfragen r1
und r2
können - undsolltenaus Leistungsgründen - gleichzeitig ausgeführt werden, aber in diesem Code werden sie nacheinander ausgeführt; die URL "http://some.url.2"
wird erst abgerufen, nachdem die Anfrage "http://some.url.1"
beendet ist. Da diese beiden Anfragen unabhängig voneinander sind, wäre es aus Leistungsgründen wahrscheinlich besser, sie gleichzeitig auszuführen.
Aber wie genau würdest du das mit einem Generator und yield
machen? Wir wissen, dass yield
nur ein einziger Pausenpunkt im Code ist, also kannst du nicht wirklich zwei Pausen gleichzeitig machen.
Die natürlichste und effektivste Antwort ist, den asynchronen Ablauf auf Promises zu stützen, insbesondere auf deren Fähigkeit, Zustände zeitunabhängig zu verwalten (siehe "Zukunftswert" in Kapitel 3).
Der einfachste Ansatz:
function
*
foo
()
{
// make both requests "in parallel"
var
p1
=
request
(
"http://some.url.1"
);
var
p2
=
request
(
"http://some.url.2"
);
// wait until both promises resolve
var
r1
=
yield
p1
;
var
r2
=
yield
p2
;
var
r3
=
yield
request
(
"http://some.url.3/?v="
+
r1
+
","
+
r2
);
console
.
log
(
r3
);
}
// use previously defined `run(..)` utility
run
(
foo
);
Warum ist das anders als der vorherige Schnipsel? Sieh dir an, woyield
steht und wo nicht. p1
und p2
sind Versprechen für Ajax-Anfragen, die gleichzeitig (also "parallel") gestellt werden. Es spielt keine Rolle, welche der beiden Anfragen zuerst beendet wird, da die Versprechen ihren aufgelösten Zustand so lange wie nötig aufrechterhalten.
Dann verwenden wir zwei aufeinander folgende yield
Anweisungen, um auf die Auflösungen der Versprechen zu warten und diese abzurufen (in r1
bzw. r2
). Wennp1
zuerst auflöst, wird yield p1
zuerst fortgesetzt und wartet dann darauf, dassyield p2
fortgesetzt wird. Wenn p2
zuerst auflöst, behält es den Auflösungswert geduldig bei, bis es gefragt wird, aber yield p1
wartet zuerst, bis p1
auflöst.
In beiden Fällen werden p1
und p2
gleichzeitig ausgeführt und müssen in der einen oder anderen Reihenfolge beendet werden, bevor die r3 = yield request..
Ajax-Anfrage gestellt wird.
Wenn dir dieses Modell der Flusskontrolle bekannt vorkommt, ist es im Grunde dasselbe, das wir in Kapitel 3 als Gate-Muster identifiziert haben und das durch das Dienstprogramm Promise.all([ .. ])
ermöglicht wird. Wir könnten die Flusskontrolle also auch so ausdrücken:
function
*
foo
()
{
// make both requests "in parallel," and
// wait until both promises resolve
var
results
=
yield
Promise
.
all
(
[
request
(
"http://some.url.1"
),
request
(
"http://some.url.2"
)
]
);
var
r1
=
results
[
0
];
var
r2
=
results
[
1
];
var
r3
=
yield
request
(
"http://some.url.3/?v="
+
r1
+
","
+
r2
);
console
.
log
(
r3
);
}
// use previously defined `run(..)` utility
run
(
foo
);
Hinweis
Wie wir in Kapitel 3 besprochen haben, können wir sogar ES6 Destrukturierungszuweisungen verwenden, um die var r1 = .. var r2 = ..
Zuweisungen zu vereinfachen, mitvar [r1,r2] = results
.
Mit anderen Worten: Alle Gleichzeitigkeitsfunktionen von Promises stehen uns im Generator + Promise-Ansatz zur Verfügung. Überall dort, wo du mehr als sequenzielle, asynchrone Ablaufsteuerungsschritte brauchst, sind Promises also die beste Wahl.
Generator Delegation
Im vorigen Abschnitt haben wir gezeigt, dass der Aufruf normaler Funktionen innerhalb eines Generators eine nützliche Technik ist, um Implementierungsdetails (wie den asynchronen Promise-Fluss) zu abstrahieren. Der größte Nachteil bei der Verwendung einer normalen Funktion für diese Aufgabe ist jedoch, dass sie sich nach den Regeln für normale Funktionen verhalten muss, d.h. sie kann sich nicht selbst mit yield
anhalten, wie es ein Generator kann.
Du könntest dann versuchen, einen Generator von einem anderen Generator aus aufzurufen, indem du unsere run(..)
Hilfe benutzt, wie z.B.:
function
*
foo
()
{
var
r2
=
yield
request
(
"http://some.url.2"
);
var
r3
=
yield
request
(
"http://some.url.3/?v="
+
r2
);
return
r3
;
}
function
*
bar
()
{
var
r1
=
yield
request
(
"http://some.url.1"
);
// "delegating" to `*foo()` via `run(..)`
var
r3
=
yield
run
(
foo
);
console
.
log
(
r3
);
}
run
(
bar
);
Wir führen *foo()
innerhalb von *bar()
aus, indem wir wieder unser Dienstprogramm run(..)
verwenden. Wir machen uns dabei die Tatsache zunutze, dass run(..)
, das wir zuvor definiert haben, ein Versprechen zurückgibt, das aufgelöst wird, wenn sein Generator zu Ende ausgeführt wird (oder einen Fehler macht). Wenn wir also yield
das Versprechen eines anderen run(..)
-Aufrufs an eine run(..)
-Instanz ausgeben, wird*bar()
automatisch angehalten, bis *foo()
fertig ist.
Aber es gibt eine noch bessere Möglichkeit, den Aufruf von *foo()
in*bar()
zu integrieren, und sie heißt yield
-delegation. Die spezielle Syntax füryield
-delegation lautet: yield * __
(beachte das zusätzliche *
). Bevor wir sehen, wie es in unserem Beispiel funktioniert, schauen wir uns ein einfacheres Szenario an:
function
*
foo
()
{
console
.
log
(
"`*foo()` starting"
);
yield
3
;
yield
4
;
console
.
log
(
"`*foo()` finished"
);
}
function
*
bar
()
{
yield
1
;
yield
2
;
yield
*
foo
();
// `yield`-delegation!
yield
5
;
}
var
it
=
bar
();
it
.
next
().
value
;
// 1
it
.
next
().
value
;
// 2
it
.
next
().
value
;
// `*foo()` starting
// 3
it
.
next
().
value
;
// 4
it
.
next
().
value
;
// `*foo()` finished
// 5
Hinweis
Ähnlich wie in einer früheren Notiz in diesem Kapitel, in der ich erklärt habe, warum ich function *foo() ..
statt function* foo() ..
bevorzuge, bevorzuge ich auch - anders als die meisten anderen Dokumentationen zu diesem Thema -yield *foo()
statt yield* foo()
. Die Platzierung von *
ist rein stilistisch und liegt in deinem Ermessen. Aber ich finde die Einheitlichkeit des Stils attraktiv.
Wie funktioniert die yield *foo()
Delegation?
Zunächst wird durch den Aufruf von foo()
ein Iterator erstellt, genau wie wir es bereits gesehen haben. Dann delegiert/überträgt yield *
die Kontrolle über die Iteratorinstanz (des aktuellen *bar()
Generators) an diesen anderen *foo()
Iterator.
Die ersten beiden Aufrufe von it.next()
kontrollieren *bar()
, aber wenn wir den dritten Aufruf von it.next()
machen, startet *foo()
, und jetzt kontrollieren wir *foo()
statt *bar()
. Deshalb nennt man es Delegation -*bar()
hat seine Iterationskontrolle an *foo()
delegiert.
Sobald die it
Iterator-Kontrolle den gesamten *foo()
Iterator ausgeschöpft hat, kehrt sie automatisch zur Kontrolle von *bar()
zurück.
Nun zurück zum vorherigen Beispiel mit den drei aufeinanderfolgenden Ajax-Anfragen:
function
*
foo
()
{
var
r2
=
yield
request
(
"http://some.url.2"
);
var
r3
=
yield
request
(
"http://some.url.3/?v="
+
r2
);
return
r3
;
}
function
*
bar
()
{
var
r1
=
yield
request
(
"http://some.url.1"
);
// "delegating" to `*foo()` via `yield*`
var
r3
=
yield
*
foo
();
console
.
log
(
r3
);
}
run
(
bar
);
Der einzige Unterschied zwischen diesem Snippet und der zuvor verwendeten Version ist die Verwendung von yield *foo()
anstelle des vorherigen yield run(foo)
.
Hinweis
yield *
gibt dir die Kontrolle über die Iteration, nicht die Kontrolle über den Generator. Wenn du den *foo()
Generator aufrufst, delegierst du jetzt yield
an seinenIterator. Du kannst aber auch yield
-delegieren;yield *[1,2,3]
würde den Standard-Iterator für den [1,2,3]
Array-Wert verwenden.
Warum Delegieren?
Der Zweck der yield
-Delegation ist vor allem die Organisation des Codes und ist in dieser Hinsicht symmetrisch zum normalen Funktionsaufruf.
Stell dir zwei Module vor, die jeweils Methoden foo()
undbar()
bereitstellen, wobei bar()
foo()
aufruft. Der Grund dafür, dass die beiden getrennt sind, liegt in der Regel darin, dass die richtige Organisation des Codes für das Programm erfordert, dass sie in getrennten Funktionen stehen. So kann es zum Beispiel vorkommen, dass foo()
eigenständig aufgerufen wird, während bar()
foo()
aufruft.
Aus genau denselben Gründen hilft die Trennung der Generatoren bei der Lesbarkeit, Wartung und Fehlersuche. In dieser Hinsicht istyield *
eine syntaktische Abkürzung für die manuelle Iteration über die Schritte von *foo()
innerhalb von *bar()
.
Ein solcher manueller Ansatz wäre besonders kompliziert, wenn die Schritte in*foo()
asynchron wären. Deshalb musst du dafür wahrscheinlich das Dienstprogrammrun(..)
verwenden. Und wie wir gezeigt haben, macht yield *foo()
eine Unterinstanz des run(..)
Dienstprogramms (wierun(foo)
) überflüssig.
Nachrichten delegieren
Du fragst dich vielleicht, wie diese yield
-Delegation nicht nur mit derIteratorsteuerung, sondern auch mit der bidirektionalen Nachrichtenübermittlung funktioniert. Verfolge aufmerksam den Fluss der Nachrichten durch die yield
-Delegation:
function
*
foo
()
{
console
.
log
(
"inside `*foo()`:"
,
yield
"B"
);
console
.
log
(
"inside `*foo()`:"
,
yield
"C"
);
return
"D"
;
}
function
*
bar
()
{
console
.
log
(
"inside `*bar()`:"
,
yield
"A"
);
// `yield`-delegation!
console
.
log
(
"inside `*bar()`:"
,
yield
*
foo
()
);
console
.
log
(
"inside `*bar()`:"
,
yield
"E"
);
return
"F"
;
}
var
it
=
bar
();
console
.
log
(
"outside:"
,
it
.
next
().
value
);
// outside: A
console
.
log
(
"outside:"
,
it
.
next
(
1
).
value
);
// inside `*bar()`: 1
// outside: B
console
.
log
(
"outside:"
,
it
.
next
(
2
).
value
);
// inside `*foo()`: 2
// outside: C
console
.
log
(
"outside:"
,
it
.
next
(
3
).
value
);
// inside `*foo()`: 3
// inside `*bar()`: D
// outside: E
console
.
log
(
"outside:"
,
it
.
next
(
4
).
value
);
// inside `*bar()`: 4
// outside: F
Achte besonders auf die Bearbeitungsschritte nach dem Aufruf vonit.next(3)
:
-
Der Wert
3
wird (über dieyield
-Delegation in*bar()
) an den wartendenyield "C"
-Ausdruck innerhalb von*foo()
übergeben. -
*foo()
ruft dannreturn "D"
auf, aber dieser Wert wird nicht vollständig an den Aufruf vonit.next(3)
zurückgegeben. -
Stattdessen wird der Wert
"D"
als Ergebnis des wartenden Ausdrucksyield *foo()
innerhalb von*bar()
gesendet - dieseryield
-Delegationsausdruck wurde im Wesentlichen angehalten, während*foo()
erschöpft war. So landet"D"
innerhalb von*bar()
und wird dort ausgedruckt. -
yield "E"
wird innerhalb von*bar()
aufgerufen, und der Wert von"E"
wird als Ergebnis des Aufrufs vonit.next(3)
nach außen weitergegeben.
Aus der Perspektive des externen Iterators (it
) scheint es keinen Unterschied zu machen, ob der ursprüngliche Generator oder ein delegierter Generator gesteuert wird.
Tatsächlich muss die yield
-Delegation nicht einmal an einen anderen Generator gerichtet sein; sie kann einfach an einen allgemeinenIterablen ohne Generator gerichtet sein. Ein Beispiel:
function
*
bar
()
{
console
.
log
(
"inside `*bar()`:"
,
yield
"A"
);
// `yield`-delegation to a non-generator!
console
.
log
(
"inside `*bar()`:"
,
yield
*
[
"B"
,
"C"
,
"D"
]
);
console
.
log
(
"inside `*bar()`:"
,
yield
"E"
);
return
"F"
;
}
var
it
=
bar
();
console
.
log
(
"outside:"
,
it
.
next
().
value
);
// outside: A
console
.
log
(
"outside:"
,
it
.
next
(
1
).
value
);
// inside `*bar()`: 1
// outside: B
console
.
log
(
"outside:"
,
it
.
next
(
2
).
value
);
// outside: C
console
.
log
(
"outside:"
,
it
.
next
(
3
).
value
);
// outside: D
console
.
log
(
"outside:"
,
it
.
next
(
4
).
value
);
// inside `*bar()`: undefined
// outside: E
console
.
log
(
"outside:"
,
it
.
next
(
5
).
value
);
// inside `*bar()`: 5
// outside: F
Beachte die Unterschiede zwischen diesem und dem vorherigen Beispiel, wo die Nachrichten empfangen/gemeldet wurden.
Am auffälligsten ist, dass der Standard-Iterator array
sich nicht um die Nachrichten kümmert, die über die Aufrufe von next(..)
gesendet werden, so dass die Werte 2
, 3
und 4
im Wesentlichen ignoriert werden. Da dieser Iterator keinen explizitenreturn
Wert hat (anders als der zuvor verwendete *foo()
), erhält der yield *
Ausdruck am Ende einen undefined
.
Auch Ausnahmen werden delegiert!
Genauso wie yield
-delegation Nachrichten transparent in beide Richtungen weitergibt, gehen auch Fehler/Ausnahmen in beide Richtungen:
function
*
foo
()
{
try
{
yield
"B"
;
}
catch
(
err
)
{
console
.
log
(
"error caught inside `*foo()`:"
,
err
);
}
yield
"C"
;
throw
"D"
;
}
function
*
bar
()
{
yield
"A"
;
try
{
yield
*
foo
();
}
catch
(
err
)
{
console
.
log
(
"error caught inside `*bar()`:"
,
err
);
}
yield
"E"
;
yield
*
baz
();
// note: can't get here!
yield
"G"
;
}
function
*
baz
()
{
throw
"F"
;
}
var
it
=
bar
();
console
.
log
(
"outside:"
,
it
.
next
().
value
);
// outside: A
console
.
log
(
"outside:"
,
it
.
next
(
1
).
value
);
// outside: B
console
.
log
(
"outside:"
,
it
.
throw
(
2
).
value
);
// error caught inside `*foo()`: 2
// outside: C
console
.
log
(
"outside:"
,
it
.
next
(
3
).
value
);
// error caught inside `*bar()`: D
// outside: E
try
{
console
.
log
(
"outside:"
,
it
.
next
(
4
).
value
);
}
catch
(
err
)
{
console
.
log
(
"error caught outside:"
,
err
);
}
// error caught outside: F
Einige Dinge, die du in diesem Ausschnitt sehen kannst:
-
Wenn wir
it.throw(2)
aufrufen, sendet es die Fehlermeldung2
an*bar()
, das sie an*foo()
delegiert, das sie danncatch
es und behandelt sie anständig. Dann sendetyield "C"
"C"
als Rückmeldungvalue
aus dem Aufruf vonit.throw(2)
zurück. -
Der
"D"
Wert, der als nächstesthrow
n von*foo()
gesendet wird, wird an*bar()
weitergegeben, dascatch
ihn erhält und ihn anständig behandelt. Dann sendetyield "E"
"E"
als Rückgabewert vonvalue
aus dem Aufruf vonit.next(3)
zurück. -
Als Nächstes wird die Ausnahme
throw
n von*baz()
nicht in*bar()
abgefangen - obwohl wircatch
sie außerhalb abgefangen haben - so dass sowohl*baz()
als auch*bar()
in einen abgeschlossenen Zustand versetzt werden. Nach diesem Snippet kannst du den Wert von"G"
nicht mehr mit einem oder mehreren nachfolgenden Aufrufen vonnext(..)
herausholen - sie geben nurundefined
fürvalue
zurück.
Delegieren von Asynchronität
Kommen wir nun zurück zu unserem früheren yield
-Delegationsbeispiel mit den mehreren aufeinanderfolgenden Ajax-Anfragen:
function
*
foo
()
{
var
r2
=
yield
request
(
"http://some.url.2"
);
var
r3
=
yield
request
(
"http://some.url.3/?v="
+
r2
);
return
r3
;
}
function
*
bar
()
{
var
r1
=
yield
request
(
"http://some.url.1"
);
var
r3
=
yield
*
foo
();
console
.
log
(
r3
);
}
run
(
bar
);
Anstatt yield run(foo)
innerhalb von *bar()
aufzurufen, rufen wir einfachyield *foo()
auf.
In der vorherigen Version dieses Beispiels wurde der Promise-Mechanismus (gesteuert von run(..)
) verwendet, um den Wert vonreturn r3
in *foo()
an die lokale Variable r3
in *bar()
zu übertragen. Jetzt wird dieser Wert direkt über die Mechanik von yield *
zurückgegeben.
Ansonsten ist das Verhalten so ziemlich identisch.
Rekursion delegieren
Natürlich kann yield
-delegation so vielen Delegationsschritten folgen, wie du verkabelt hast. Du könntest sogar yield
-delegation für eine asynchrone Generatorrekursionverwenden - einGenerator yield
-delegiert an sich selbst:
function
*
foo
(
val
)
{
if
(
val
>
1
)
{
// generator recursion
val
=
yield
*
foo
(
val
-
1
);
}
return
yield
request
(
"http://some.url/?v="
+
val
);
}
function
*
bar
()
{
var
r1
=
yield
*
foo
(
3
);
console
.
log
(
r1
);
}
run
(
bar
);
Hinweis
Unser run(..)
Dienstprogramm hätte auch mitrun( foo, 3 )
aufgerufen werden können, da es zusätzliche Parameter unterstützt, die bei der Initialisierung des Generators mitgegeben werden. Wir haben hier jedoch ein parameterloses *bar()
verwendet, um die Flexibilität von yield *
zu unterstreichen.
Welche Verarbeitungsschritte folgen aus diesem Code? Warte, das wird ziemlich kompliziert, um es im Detail zu beschreiben:
-
run(bar)
schaltet den Generator*bar()
ein. -
foo(3)
erstellt einen Iterator für*foo(..)
und übergibt3
als seinen Parameterval
. -
Denn
3 > 1
,foo(2)
erstellt einen weiteren Iterator und übergibt2
als seinenval
Parameter. -
Denn
2 > 1
,foo(1)
erstellt einen weiteren Iterator und übergibt1
als seinenval
Parameter. -
1 > 1
istfalse
, also rufen wir als Nächstesrequest(..)
mit dem Wert1
auf und bekommen ein Versprechen für diesen ersten Ajax-Aufruf zurück. -
Dieses Versprechen wird unter
yield
veröffentlicht, das an die*foo(2)
Generatorinstanz zurückgegeben wird. -
Die
yield *
gibt das Versprechen an die*foo(3)
Generatorinstanz zurück. Eine weitereyield *
gibt das Versprechen an die*bar()
Generatorinstanz weiter. Und wieder gibt einyield *
das Versprechen an dasrun(..)
Dienstprogramm weiter, das auf dieses Versprechen (für die erste Ajax-Anfrage) wartet, um fortzufahren. -
Wenn das Versprechen aufgelöst wird, wird seine Erfüllungsnachricht an die
*bar()
gesendet, die über dieyield *
in die*foo(3)
Instanz gelangt, die wiederum über dieyield *
in die*foo(2)
Generatorinstanz gelangt, die wiederum über dieyield *
in die normaleyield
Instanz, die in der*foo(3)
Generatorinstanz wartet. -
Die Ajax-Antwort des ersten Aufrufs wird jetzt sofort
return
von der Generatorinstanz*foo(3)
gesendet, die diesen Wert als Ergebnis des Ausdrucksyield *
in der Instanz*foo(2)
zurücksendet und der lokalen Variableval
zuweist. -
Innerhalb von
*foo(2)
wird eine zweite Ajax-Anfrage mitrequest(..)
gestellt, deren Versprechenyield
an die Instanz*foo(1)
zurückgegeben wird und dannyield *
bis zurun(..)
weitergegeben wird (wieder Schritt 7). Wenn das Versprechen aufgelöst wird, wird die zweite Ajax-Antwort zurück in die Instanz des*foo(2)
-Generators übertragen und der lokalen Variableval
zugewiesen. -
Schließlich wird die dritte Ajax-Anfrage mit
request(..)
gestellt, ihr Versprechen geht raus anrun(..)
und dann kommt ihr Auflösungswert den ganzen Weg zurück, der dannreturn
ed ist, so dass er zurück zum wartendenyield *
Ausdruck in*bar()
kommt.
Puh! Eine Menge verrücktes mentales Jonglieren, was? Vielleicht solltest du dir das noch ein paar Mal durchlesen und dann einen Snack essen gehen, um den Kopf frei zu bekommen!
Generator-Gleichzeitigkeit
Wie wir bereits in Kapitel 1 und in diesem Kapitel besprochen haben, können zwei gleichzeitig ablaufende "Prozesse" ihre Operationen miteinander verschränken, was in vielen Fällen (Wortspiel beabsichtigt) zu sehr mächtigen Asynchronitätsausdrücken führen kann.
Ehrlich gesagt, haben unsere früheren Beispiele für die Gleichzeitigkeit mehrerer Generatoren gezeigt, wie man es wirklich verwirrend machen kann. Aber wir haben angedeutet, dass es Orte gibt, an denen diese Fähigkeit sehr nützlich ist.
Erinnere dich an ein Szenario aus Kapitel 1, in dem zwei verschiedene Ajax-Antwort-Handler gleichzeitig miteinander koordiniert werden mussten, um sicherzustellen, dass die Datenkommunikation nicht zu einer Wettlaufsituation führt. Wir haben die Antworten wie folgt in das Array res
eingefügt:
function
response
(
data
)
{
if
(
data
.
url
==
"http://some.url.1"
)
{
res
[
0
]
=
data
;
}
else
if
(
data
.
url
==
"http://some.url.2"
)
{
res
[
1
]
=
data
;
}
}
Aber wie können wir mehrere Generatoren gleichzeitig für dieses Szenario nutzen?
// `request(..)` is a Promise-aware Ajax utility
var
res
=
[];
function
*
reqData
(
url
)
{
res
.
push
(
yield
request
(
url
)
);
}
Hinweis
Wir werden hier zwei Instanzen des Generators *reqData(..)
verwenden, aber es gibt keinen Unterschied zu einer einzelnen Instanz von zwei verschiedenen Generatoren; beide Ansätze werden auf die gleiche Weise begründet. Wir werden gleich sehen, wie sich zwei verschiedene Generatoren koordinieren.
Anstatt die Zuweisungen von res[0]
und res[1]
manuell zu sortieren, verwenden wir eine koordinierte Reihenfolge, damit res.push(..)
die Werte in der erwarteten und vorhersehbaren Reihenfolge einfügt. Die ausgedrückte Logik sollte sich dadurch ein bisschen sauberer anfühlen.
Aber wie werden wir diese Interaktion tatsächlich organisieren? Zuerst machen wir es einfach manuell, mit Promises:
var
it1
=
reqData
(
"http://some.url.1"
);
var
it2
=
reqData
(
"http://some.url.2"
);
var
p1
=
it1
.
next
();
var
p2
=
it2
.
next
();
p1
.
then
(
function
(
data
){
it1
.
next
(
data
);
return
p2
;
}
)
.
then
(
function
(
data
){
it2
.
next
(
data
);
}
);
*reqData(..)
Die beiden Instanzen werden beide gestartet, um ihre Ajax-Anfragen zu stellen, und dann mit yield
pausiert. Dann entscheiden wir uns, die erste Instanz fortzusetzen, wenn p1
aufgelöst wird, und die Auflösung von p2
wird die zweite Instanz neu starten. Auf diese Weise stellen wir mit der Promise-Orchestrierung sicher, dass res[0]
die erste Antwort und res[1]
die zweite Antwort erhält.
Aber ehrlich gesagt, ist das sehr manuell und lässt die Generatoren nicht wirklich selbst orchestrieren, wo die wahre Macht liegen kann. Versuchen wir es auf eine andere Art:
// `request(..)` is a Promise-aware Ajax utility
var
res
=
[];
function
*
reqData
(
url
)
{
var
data
=
yield
request
(
url
);
// transfer control
yield
;
res
.
push
(
data
);
}
var
it1
=
reqData
(
"http://some.url.1"
);
var
it2
=
reqData
(
"http://some.url.2"
);
var
p1
=
it
.
next
();
var
p2
=
it
.
next
();
p1
.
then
(
function
(
data
){
it1
.
next
(
data
);
}
);
p2
.
then
(
function
(
data
){
it2
.
next
(
data
);
}
);
Promise
.
all
(
[
p1
,
p2
]
)
.
then
(
function
(){
it1
.
next
();
it2
.
next
();
}
);
Okay, das ist schon etwas besser (aber immer noch manuell!), denn jetzt laufen die beiden Instanzen von *reqData(..)
wirklich gleichzeitig und (zumindest für den ersten Teil) unabhängig voneinander.
Im vorigen Schnipsel hat die zweite Instanz ihre Daten erst erhalten, nachdem die erste Instanz vollständig fertig war. Hier jedoch erhalten beide Instanzen ihre Daten, sobald ihre jeweiligen Antworten zurückkommen, und dann führt jede Instanz eine weitere yield
zur Kontrollübertragung durch. Wir entscheiden dann, in welcher Reihenfolge wir sie imPromise.all([ .. ])
Handler fortsetzen.
Was vielleicht nicht so offensichtlich ist, ist die Tatsache, dass dieser Ansatz aufgrund der Symmetrie eine einfachere Form für ein wiederverwendbares Dienstprogramm nahelegt. Wir können es noch besser machen: Stellen wir uns ein Dienstprogramm namens runAll(..)
vor:
// `request(..)` is a Promise-aware Ajax utility
var
res
=
[];
runAll
(
function
*
(){
var
p1
=
request
(
"http://some.url.1"
);
// transfer control
yield
;
res
.
push
(
yield
p1
);
},
function
*
(){
var
p2
=
request
(
"http://some.url.2"
);
// transfer control
yield
;
res
.
push
(
yield
p2
);
}
);
Hinweis
Wir verzichten auf eine Auflistung des Codes für runAll(..)
, da er nicht nur lang genug ist, um den Text zu überfrachten, sondern auch eine Erweiterung der Logik darstellt, die wir bereits in run(..)
implementiert haben. Als ergänzende Übung für den Leser kannst du versuchen, den Code von run(..)
so weiterzuentwickeln, dass er so funktioniert wie der von runAll(..)
. Außerdem bietet meineAsynquence-Bibliothek ein bereits erwähntes runner(..)
Dienstprogramm, in dem diese Art von Fähigkeit bereits eingebaut ist und das in Anhang A dieses Buches besprochen wird.
So würde die Verarbeitung innerhalb von runAll(..)
funktionieren:
-
Der erste Generator erhält ein Versprechen für die erste Ajax-Antwort von
"http://some.url.1"
, dannyield
s Kontrolle zurück an das DienstprogrammrunAll(..)
. -
Der zweite Generator läuft und macht das Gleiche für
"http://some.url.2"
,yield
ing control back to therunAll(..)
utility. -
Der erste Generator wird fortgesetzt, und dann gibt
yield
sein Versprechenp1
ab. Das DienstprogrammrunAll(..)
tut in diesem Fall dasselbe wie unser vorherigesrun(..)
, indem es auf die Auflösung des Versprechens wartet und dann denselben Generator wieder aufnimmt (keine Kontrollübertragung!). Wennp1
aufgelöst wird, setztrunAll(..)
den ersten Generator mit diesem Auflösungswert fort undres[0]
erhält seinen Wert. Wenn der erste Generator dann beendet wird, ist das eine implizite Übergabe der Kontrolle. -
Der zweite Generator wird fortgesetzt,
yield
gibt sein Versprechenp2
ab und wartet darauf, dass es aufgelöst wird. Sobald dies der Fall ist, setztrunAll(..)
den zweiten Generator mit diesem Wert fort undres[1]
wird gesetzt.
In diesem Beispiel verwenden wir eine äußere Variable namens res
, um die Ergebnisse der beiden verschiedenen Ajax-Antworten zu speichern - unsere Gleichzeitigkeitskoordination macht das möglich.
Es könnte jedoch sehr hilfreich sein, runAll(..)
weiter auszubauen, um einen inneren Variablenraum für mehrere Generatorinstanzen zur Verfügung zu stellen, z. B. ein leeres Objekt, das wir im Folgenden data
nennen. Außerdem könnte es Werte, die nicht zu den Versprechen gehören, an den nächsten Generator weitergeben ( yield
).
Bedenke:
// `request(..)` is a Promise-aware Ajax utility
runAll
(
function
*
(
data
){
data
.
res
=
[];
// transfer control (and message pass)
var
url1
=
yield
"http://some.url.2"
;
var
p1
=
request
(
url1
);
// "http://some.url.1"
// transfer control
yield
;
data
.
res
.
push
(
yield
p1
);
},
function
*
(
data
){
// transfer control (and message pass)
var
url2
=
yield
"http://some.url.1"
;
var
p2
=
request
(
url2
);
// "http://some.url.2"
// transfer control
yield
;
data
.
res
.
push
(
yield
p2
);
}
);
In dieser Formulierung koordinieren die beiden Generatoren nicht nur die Kontrollübertragung, sondern kommunizieren tatsächlich miteinander, sowohl über data.res
als auch über die yield
ed Nachrichten, die url1
undurl2
Werte austauschen. Das ist unglaublich mächtig!
Eine solche Umsetzung dient auch als konzeptionelle Grundlage für eine ausgefeiltere Asynchronitätstechnik namens "Communicating Sequential Processes" (CSP), die in Anhang B dieses Buches behandelt wird.
Thunks
Bisher sind wir davon ausgegangen, dass yield
ein Promise von einem Generator erhält und dass dieses Promise den Generator über ein Hilfsprogramm wie run(..)
fortsetzt - die bestmögliche Art, Asynchronität mit Generatoren zu verwalten. Um es klar zu sagen: Das ist es auch.
Aber wir haben ein anderes Muster übersprungen, das einigermaßen weit verbreitet ist, also werfen wir im Interesse der Vollständigkeit einen kurzen Blick darauf.
In der Informatik gibt es ein altes Konzept aus der Vor-JS-Zeit, dasThunk genannt wird. Ohne sich in der Geschichte zu verzetteln, ist ein Thunk in JS eine Funktion, die - ohne Parameter - eine andere Funktion aufrufen soll.
Mit anderen Worten: Du verpackst eine Funktionsdefinition um einen Funktionsaufruf herum - mit allen Parametern, die sie braucht -, um die Ausführung dieses Aufrufs aufzuschieben, und diese verpackende Funktion ist ein Thunk. Wenn du den Thunk später ausführst, rufst du am Ende die ursprüngliche Funktion auf.
Zum Beispiel:
function
foo
(
x
,
y
)
{
return
x
+
y
;
}
function
fooThunk
()
{
return
foo
(
3
,
4
);
}
// later
console
.
log
(
fooThunk
()
);
// 7
Ein synchroner Thunk ist also ziemlich einfach. Aber was ist mit einem asynchronen Thunk? Wir können die enge Thunk-Definition im Wesentlichen dahingehend erweitern, dass sie einen Callback empfängt.
Bedenke:
function
foo
(
x
,
y
,
cb
)
{
setTimeout
(
function
(){
cb
(
x
+
y
);
},
1000
);
}
function
fooThunk
(
cb
)
{
foo
(
3
,
4
,
cb
);
}
// later
fooThunk
(
function
(
sum
){
console
.
log
(
sum
);
// 7
}
);
Wie du siehst, erwartet fooThunk(..)
nur einen cb(..)
Parameter, da er bereits die Werte 3
und 4
(für x
bzw. y
) vordefiniert hat, die er an foo(..)
übergeben kann. Ein Thunk wartet geduldig auf das letzte Teil, das er braucht, um seine Aufgabe zu erfüllen: den Callback.
Du willst die Thunks aber nicht manuell erstellen. Erfinden wir also ein Dienstprogramm, das diese Wraps für uns erledigt.
Bedenke:
function
thunkify
(
fn
)
{
var
args
=
[].
slice
.
call
(
arguments
,
1
);
return
function
(
cb
)
{
args
.
push
(
cb
);
return
fn
.
apply
(
null
,
args
);
};
}
var
fooThunk
=
thunkify
(
foo
,
3
,
4
);
// later
fooThunk
(
function
(
sum
)
{
console
.
log
(
sum
);
// 7
}
);
Tipp
Hier gehen wir davon aus, dass die ursprüngliche (foo(..)
) Funktionssignatur ihren Callback an letzter Stelle erwartet, wobei alle anderen Parameter davor stehen. Dies ist ein ziemlich allgegenwärtiger Standard für asynchrone JS-Funktionsstandards. Man könnte es "Callback-Last-Style" nennen. Wenn du aus irgendeinem Grund mit Signaturen im "Callback-First-Stil" umgehen müsstest, würdest du einfach ein Dienstprogramm erstellen, das args.unshift(..)
stattargs.push(..)
verwendet.
Die vorangegangene Formulierung von thunkify(..)
nimmt sowohl die Funktionsreferenz foo(..)
als auch alle benötigten Parameter auf und gibt den Thunk selbst zurück (fooThunk(..)
). Das ist jedoch nicht der typische Ansatz für Thunks in JS.
Anstatt dass thunkify(..)
den Thunk selbst erstellt, erzeugt das Dienstprogramm thunkify(..)
typischerweise eine Funktion, die Thunks produziert - auch wenn das nicht verwirrend ist.
Uhhhh...ja.
Bedenke:
function
thunkify
(
fn
)
{
return
function
()
{
var
args
=
[].
slice
.
call
(
arguments
);
return
function
(
cb
)
{
args
.
push
(
cb
);
return
fn
.
apply
(
null
,
args
);
};
};
}
Der Hauptunterschied ist die zusätzliche Schicht return function() { .. }
. Hier siehst du, wie sich die Verwendung unterscheidet:
var
whatIsThis
=
thunkify
(
foo
);
var
fooThunk
=
whatIsThis
(
3
,
4
);
// later
fooThunk
(
function
(
sum
)
{
console
.
log
(
sum
);
// 7
}
);
Die große Frage, die dieses Snippet aufwirft, ist natürlich, wie whatIsThis
richtig heißt. Es ist nicht der Thunk, sondern das Ding, das Thunks aus den Aufrufen von foo(..)
erzeugt. Es ist so etwas wie eine "Fabrik" für "Thunks". Es scheint keine Standardvereinbarung für die Benennung eines solchen Dings zu geben.
Mein Vorschlag lautet daher "thunkory" ("thunk" + "factory"). thunkify(..)
erzeugt also eine Thunkory, und eine Thunkory erzeugt Thunks. Diese Argumentation ist symmetrisch zu meinem Vorschlag für "promisory" in Kapitel 3:
var
fooThunkory
=
thunkify
(
foo
);
var
fooThunk1
=
fooThunkory
(
3
,
4
);
var
fooThunk2
=
fooThunkory
(
5
,
6
);
// later
fooThunk1
(
function
(
sum
)
{
console
.
log
(
sum
);
// 7
}
);
fooThunk2
(
function
(
sum
)
{
console
.
log
(
sum
);
// 11
}
);
Hinweis
Das laufende foo(..)
Beispiel erwartet einen Rückruf, der nicht im "error-first style" erfolgt. Natürlich ist der "error-first style" viel verbreiteter. Wenn foo(..)
eine legitime Fehlererwartung hätte, könnten wir es so ändern, dass es einen Fehler-ersten Callback erwartet und verwendet. Keiner der nachfolgenden thunkify(..)
Mechanismen kümmert sich darum, welche Art von Rückruf angenommen wird. Der einzige Unterschied in der Verwendung wärefooThunk1(function(err,sum){..
.
Die Thunkory-Methode offenzulegen - im Gegensatz zuthunkify(..)
, wo dieser Zwischenschritt versteckt ist - mag als unnötige Komplikation erscheinen. Aber im Allgemeinen ist es sehr nützlich, am Anfang deines Programms Thunkories zu erstellen, um bestehende API-Methoden zu umhüllen, und diese Thunkories dann weiterzugeben und aufzurufen, wenn du Thunks brauchst. Durch die zwei unterschiedlichen Schritte wird eine saubere Trennung der Fähigkeiten beibehalten.
Zur Veranschaulichung:
// cleaner:
var
fooThunkory
=
thunkify
(
foo
);
var
fooThunk1
=
fooThunkory
(
3
,
4
);
var
fooThunk2
=
fooThunkory
(
5
,
6
);
// instead of:
var
fooThunk1
=
thunkify
(
foo
,
3
,
4
);
var
fooThunk2
=
thunkify
(
foo
,
5
,
6
);
Unabhängig davon, ob du dich explizit mit den Thunks beschäftigen möchtest, bleibt die Verwendung der Thunks fooThunk1(..)
und fooThunk2(..)
gleich.
s/versprechen/thunk/
Was hat dieser ganze Quatsch mit Generatoren zu tun?
Wenn du Thunks mit Versprechen vergleichst: Sie sind nicht direkt austauschbar, da sie sich nicht gleich verhalten. Versprechen sind weitaus leistungsfähiger und vertrauenswürdiger als bloße Thunks.
Aber in einem anderen Sinne können sie beide als eine Anfrage nach einem Wert gesehen werden, die asynchron beantwortet werden kann.
Erinnere dich daran, dass wir in Kapitel 3 ein Dienstprogramm für das Versprechen einer Funktion definiert haben, das wir Promise.wrap(..)
nannten - wir hätten es auchpromisify(..)
nennen können! Dieses Dienstprogramm zum Einpacken von Versprechen erzeugt keine Versprechen, sondern Promisories, die wiederum Versprechen erzeugen. Das ist völlig symmetrisch zu den Thunkories und Thunks, die wir gerade besprechen.
Um die Symmetrie zu verdeutlichen, ändern wir zunächst das laufende foo(..)
Beispiel von vorhin, um einen "error-first style" Callback anzunehmen:
function
foo
(
x
,
y
,
cb
)
{
setTimeout
(
function
(){
// assume `cb(..)` as "error-first style"
cb
(
null
,
x
+
y
);
},
1000
);
}
Jetzt vergleichen wir mit thunkify(..)
und promisify(..)
(auch bekannt alsPromise.wrap(..)
aus Kapitel 3):
// symmetrical: constructing the question asker
var
fooThunkory
=
thunkify
(
foo
);
var
fooPromisory
=
promisify
(
foo
);
// symmetrical: asking the question
var
fooThunk
=
fooThunkory
(
3
,
4
);
var
fooPromise
=
fooPromisory
(
3
,
4
);
// get the thunk answer
fooThunk
(
function
(
err
,
sum
){
if
(
err
)
{
console
.
error
(
err
);
}
else
{
console
.
log
(
sum
);
// 7
}
}
);
// get the promise answer
fooPromise
.
then
(
function
(
sum
){
console
.
log
(
sum
);
// 7
},
function
(
err
){
console
.
error
(
err
);
}
);
Sowohl die Thunkory als auch das Promisory stellen im Wesentlichen eine Frage (nach einem Wert), und die Thunkory fooThunk
bzw. PromisoryfooPromise
stellen die zukünftigen Antworten auf diese Frage dar. So gesehen, ist die Symmetrie klar.
Mit dieser Perspektive im Hinterkopf können wir sehen, dass Generatoren, die yield
Promises für Asynchronität verwenden, stattdessen yield
thunks für Asynchronität verwenden könnten. Alles, was wir dazu brauchen, ist ein intelligenteres run(..)
Dienstprogramm (wie das vorherige), das nicht nur nach einer yield
Promise suchen und eine Verbindung zu ihr herstellen kann, sondern auch einen Callback für einen yield
Thunk bereitstellt.
Bedenke:
function
*
foo
()
{
var
val
=
yield
request
(
"http://some.url.1"
);
console
.
log
(
val
);
}
run
(
foo
);
In diesem Beispiel könnte request(..)
entweder ein Promisory sein, das ein Versprechen zurückgibt, oder ein Thunkory, das einen Thunk zurückgibt. Aus der Perspektive der Logik des Generatorcodes ist uns dieses Implementierungsdetail egal, und das ist ziemlich mächtig!
request(..)
könnte also beides sein:
// promisory `request(..)` (see Chapter 3)
var
request
=
Promise
.
wrap
(
ajax
);
// vs.
// thunkory `request(..)`
var
request
=
thunkify
(
ajax
);
Als Thunk-aware Patch für unser früheres run(..)
Dienstprogramm bräuchten wir eine Logik wie diese:
// ..
// did we receive a thunk back?
else
if
(
typeof
next
.
value
==
"function"
)
{
return
new
Promise
(
function
(
resolve
,
reject
){
// call the thunk with an error-first callback
next
.
value
(
function
(
err
,
msg
)
{
if
(
err
)
{
reject
(
err
);
}
else
{
resolve
(
msg
);
}
}
);
}
)
.
then
(
handleNext
,
function
handleErr
(
err
)
{
return
Promise
.
resolve
(
it
.
throw
(
err
)
)
.
then
(
handleResult
);
}
);
}
Jetzt können unsere Generatoren entweder Promisories aufrufen, um yield
Promises zu erzeugen, oder Thunkories aufrufen, um yield
Thunks zu erzeugen. In beiden Fällen würde run(..)
diesen Wert verarbeiten und ihn verwenden, um auf die Fertigstellung zu warten und den Generator fortzusetzen.
Von der Symmetrie her sehen diese beiden Ansätze identisch aus. Wir sollten jedoch darauf hinweisen, dass dies nur aus der Perspektive von Promises oder Thunks gilt, die die zukünftige Wertfortschreibung eines Generators darstellen.
Im Großen und Ganzen haben Thunks an und für sich keine der Garantien für Vertrauenswürdigkeit oder Zusammensetzbarkeit, mit denen Promises ausgestattet sind. Die Verwendung eines Thunks als Ersatz für ein Promise in diesem speziellen Generator-Asynchronie-Muster ist praktikabel, sollte aber im Vergleich zu den Vorteilen, die Promises bieten, als weniger ideal angesehen werden (siehe Kapitel 3).
Wenn du die Möglichkeit hast, verwende yield pr
statt yield th
. Es spricht aber nichts dagegen, ein Dienstprogramm run(..)
zu haben, das beide Wertetypen verarbeiten kann.
Hinweis
Das Dienstprogramm runner(..)
in meiner Asynquence-Bibliothek, das in Anhang A beschrieben wird, behandelt yield
s von Promises, Thunks undAsynquence-Sequenzen.
Vor-ES6-Generatoren
Du bist jetzt hoffentlich davon überzeugt, dass Generatoren eine sehr wichtige Ergänzung des Werkzeugkastens für die asynchrone Programmierung sind. Aber es handelt sich um eine neue Syntax in ES6, was bedeutet, dass man Generatoren nicht einfach mit Polyfills versehen kann wie Promises (die nur eine neue API sind). Was können wir also tun, um Generatoren in unseren Browser JS zu bringen, wenn wir nicht den Luxus haben, Pre-ES6-Browser zu ignorieren?
Für alle neuen Syntaxerweiterungen in ES6 gibt es Tools - der gebräuchlichste Begriff für sie ist Transpiler, für Trans-Compiler - die deine ES6-Syntax in äquivalenten (aber natürlich hässlicheren!) Pre-ES6-Code umwandeln können. So können Generatoren in Code umgewandelt werden, der sich genauso verhält, aber in ES5 und darunter funktioniert.
Aber wie? Die "Magie" von yield
klingt nicht gerade nach Code, der leicht zu übertragen ist. In unserer früheren Diskussion über schließungsbasierte Iteratoren haben wir bereits eine Lösung angedeutet.
Manuelle Umwandlung
Bevor wir uns mit den Transpilern befassen, wollen wir ableiten, wie die manuelle Transpilierung bei Generatoren funktionieren würde. Das ist nicht nur eine akademische Übung, denn dadurch wird die Funktionsweise der Generatoren noch deutlicher.
Bedenke:
// `request(..)` is a Promise-aware Ajax utility
function
*
foo
(
url
)
{
try
{
console
.
log
(
"requesting:"
,
url
);
var
val
=
yield
request
(
url
);
console
.
log
(
val
);
}
catch
(
err
)
{
console
.
log
(
"Oops:"
,
err
);
return
false
;
}
}
var
it
=
foo
(
"http://some.url.1"
);
Als Erstes müssen wir feststellen, dass wir immer noch eine normale foo()
Funktion brauchen, die aufgerufen werden kann und die einenIterator zurückgeben muss. Skizzieren wir also die Umwandlung ohne Generatoren:
function
foo
(
url
)
{
// ..
// make and return an iterator
return
{
next
:
function
(
v
)
{
// ..
},
throw
:
function
(
e
)
{
// ..
}
};
}
var
it
=
foo
(
"http://some.url.1"
);
Als Nächstes ist zu beachten, dass ein Generator seine "Magie" ausübt, indem er seinen Bereich/Zustand außer Kraft setzt, aber wir können das mit Funktionsschließungen nachahmen (siehe den Titel Scope & Closures in dieser Serie). Um zu verstehen, wie wir einen solchen Code schreiben können, werden wir zunächst verschiedene Teile unseres Generators mit Zustandswerten annotieren:
// `request(..)` is a Promise-aware Ajax utility
function
*
foo
(
url
)
{
// STATE 1
try
{
console
.
log
(
"requesting:"
,
url
);
var
TMP1
=
request
(
url
);
// STATE 2
var
val
=
yield
TMP1
;
console
.
log
(
val
);
}
catch
(
err
)
{
// STATE 3
console
.
log
(
"Oops:"
,
err
);
return
false
;
}
}
Hinweis
Zur genaueren Veranschaulichung teilen wir die Anweisungval = yield request..
in zwei Teile auf, indem wir die temporäre VariableTMP1
verwenden. request(..)
wird im Zustand 1
ausgeführt, und die Zuweisung des Abschlusswerts an val
erfolgt im Zustand 2
. Wir werden die Zwischenanweisung TMP1
loswerden, wenn wir den Code in sein Äquivalent ohne Generator umwandeln.
Mit anderen Worten: 1
ist der Anfangszustand, 2
ist der Zustand, wennrequest(..)
erfolgreich ist, und 3
ist der Zustand, wenn request(..)
fehlschlägt. Du kannst dir wahrscheinlich vorstellen, dass alle zusätzlichen yield
Schritte einfach als zusätzliche Zustände kodiert werden würden.
Kehren wir zu unserem transpilierten Generator zurück und definieren wir eine Variable state
in der Closure, mit der wir den Status verfolgen können:
function
foo
(
url
)
{
// manage generator state
var
state
;
// ..
}
Definieren wir nun eine innere Funktion namens process(..)
innerhalb der Closure, die jeden Zustand behandelt, indem wir eine switch
Anweisung verwenden:
// `request(..)` is a Promise-aware Ajax utility
function
foo
(
url
)
{
// manage generator state
var
state
;
// generator-wide variable declarations
var
val
;
function
process
(
v
)
{
switch
(
state
)
{
case
1
:
console
.
log
(
"requesting:"
,
url
);
return
request
(
url
);
case
2
:
val
=
v
;
console
.
log
(
val
);
return
;
case
3
:
var
err
=
v
;
console
.
log
(
"Oops:"
,
err
);
return
false
;
}
}
// ..
}
Jeder Zustand in unserem Generator wird durch eine eigene case
in derswitch
Anweisung dargestellt. process(..)
wird jedes Mal aufgerufen, wenn wir einen neuen Zustand verarbeiten müssen. Wir werden gleich darauf zurückkommen, wie das funktioniert.
Die Deklarationen der generatorweiten Variablen (val
) verschieben wir in einevar
Deklaration außerhalb von process(..)
, damit sie mehrere Aufrufe von process(..)
überleben können. Die blockspezifische Variable err
wird nur für den 3
Zustand benötigt, also lassen wir sie an Ort und Stelle.
Im Zustand 1
haben wir anstelle von yield resolve(..)
return resolve(..)
gemacht. Im Endzustand 2
gab es kein explizitesreturn
, also machen wir einfach ein return;
, was dasselbe ist wiereturn undefined
. Im Endzustand 3
gab es eine return false
, also behalten wir diese bei.
Jetzt müssen wir den Code in den Iteratorfunktionen definieren, damit sieprocess(..)
entsprechend aufrufen:
function
foo
(
url
)
{
// manage generator state
var
state
;
// generator-wide variable declarations
var
val
;
function
process
(
v
)
{
switch
(
state
)
{
case
1
:
console
.
log
(
"requesting:"
,
url
);
return
request
(
url
);
case
2
:
val
=
v
;
console
.
log
(
val
);
return
;
case
3
:
var
err
=
v
;
console
.
log
(
"Oops:"
,
err
);
return
false
;
}
}
// make and return an iterator
return
{
next
:
function
(
v
)
{
// initial state
if
(
!
state
)
{
state
=
1
;
return
{
done
:
false
,
value
:
process
()
};
}
// yield resumed successfully
else
if
(
state
==
1
)
{
state
=
2
;
return
{
done
:
true
,
value
:
process
(
v
)
};
}
// generator already completed
else
{
return
{
done
:
true
,
value
:
undefined
};
}
},
"throw"
:
function
(
e
)
{
// the only explicit error handling is in
// state 1
if
(
state
==
1
)
{
state
=
3
;
return
{
done
:
true
,
value
:
process
(
e
)
};
}
// otherwise, an error won't be handled,
// so just throw it right back out
else
{
throw
e
;
}
}
};
}
Wie funktioniert dieser Code?
-
Der erste Aufruf des Iterators
next()
würde den Generator vom unitialisierten Zustand in den Zustand1
versetzen und dannprocess()
aufrufen, um diesen Zustand zu behandeln. Der Rückgabewert vonrequest(..)
, der das Versprechen für die Ajax-Antwort ist, wird als Eigenschaftvalue
vom Aufrufnext()
zurückgegeben. -
Wenn die Ajax-Anfrage erfolgreich war, sollte der zweite Aufruf von
next(..)
den Ajax-Antwortwert übermitteln, der unseren Status nach2
verschiebt.process(..)
wird erneut aufgerufen (diesmal mit dem übergebenen Ajax-Antwortwert), und die vonnext(..)
zurückgegebene Eigenschaftvalue
wirdundefined
sein. -
Wenn die Ajax-Anfrage jedoch fehlschlägt, sollte
throw(..)
mit dem Fehler aufgerufen werden, wodurch der Status von1
nach3
(statt nach2
) verschoben würde. Erneut wirdprocess(..)
aufgerufen, dieses Mal mit dem Fehlerwert.case
gibtfalse
zurück, das als Eigenschaftvalue
gesetzt wird, die vom Aufrufthrow(..)
zurückgegeben wird.
Von außen betrachtet, d.h. wenn du nur mit dem Iteratorinteragierst ,funktioniert diesenormale Funktion von foo(..)
so ziemlich genauso wie der Generator von*foo(..)
. Wir haben also unseren ES6-Generator effektiv auf die Zeit vor ES6 zurückgeführt!
Wir könnten dann unseren Generator manuell instanziieren und seinen Iterator steuern - indem wir var it = foo("..")
und it.next(..)
und so weiter aufrufen - oder noch besser, wir könnten ihn an unser zuvor definiertes Dienstprogramm run(..)
als run(foo,"..")
übergeben.
Automatische Transpilation
Die vorangegangene Übung, bei der wir unseren ES6-Generator manuell in das Prä-ES6-Äquivalent umgewandelt haben, hat uns gezeigt, wie Generatoren konzeptionell funktionieren. Aber diese Umwandlung war sehr kompliziert und nicht auf andere Generatoren in unserem Code übertragbar. Es wäre ziemlich unpraktisch, diese Arbeit von Hand zu machen, und würde den Nutzen der Generatoren komplett zunichte machen.
Aber zum Glück gibt es bereits einige Tools, die ES6-Generatoren automatisch in das umwandeln können, was wir im vorherigen Abschnitt abgeleitet haben. Sie nehmen uns nicht nur die schwere Arbeit ab, sondern kümmern sich auch um einige Komplikationen, die wir übergangen haben.
Ein solches Tool ist Regenerator von den cleveren Leuten bei Facebook.
Wenn wir den Regenerator verwenden, um unseren vorherigen Generator zu übersetzen, entsteht folgender Code (zum Zeitpunkt der Erstellung dieses Artikels):
// `request(..)` is a Promise-aware Ajax utility
var
foo
=
regeneratorRuntime
.
mark
(
function
foo
(
url
)
{
var
val
;
return
regeneratorRuntime
.
wrap
(
function
foo$
(
context$1$0
)
{
while
(
1
)
switch
(
context$1$0
.
prev
=
context$1$0
.
next
)
{
case
0
:
context$1$0
.
prev
=
0
;
console
.
log
(
"requesting:"
,
url
);
context$1$0
.
next
=
4
;
return
request
(
url
);
case
4
:
val
=
context$1$0
.
sent
;
console
.
log
(
val
);
context$1$0
.
next
=
12
;
break
;
case
8
:
context$1$0
.
prev
=
8
;
context$1$0
.
t0
=
context$1$0
.
catch
(
0
);
console
.
log
(
"Oops:"
,
context$1$0
.
t0
);
return
context$1$0
.
abrupt
(
"return"
,
false
);
case
12
:
case
"end"
:
return
context$1$0
.
stop
();
}
},
foo
,
this
,
[[
0
,
8
]]);
});
Es gibt hier einige offensichtliche Ähnlichkeiten zu unserer manuellen Ableitung, wie z.B. die switch
/ case
Anweisungen, und wir sehen sogar, dass val
aus der Schließung herausgezogen wurde, genau wie wir es getan haben.
Ein Kompromiss besteht natürlich darin, dass die Transpilierung des Regenerators eine Hilfsbibliothek regeneratorRuntime
erfordert, die die gesamte wiederverwendbare Logik für die Verwaltung eines allgemeinen Generators/Iterators enthält. Vieles in dieser Bibliothek sieht anders aus als in unserer Version, aber auch hier sind die Konzepte erkennbar, z. B. context$1$0.next = 4
, das den nächsten Zustand des Generators festhält.
Die wichtigste Erkenntnis ist, dass Generatoren nicht nur in ES6+ Umgebungen nützlich sind. Sobald du die Konzepte verstanden hast, kannst du sie in deinem gesamten Code einsetzen und Tools verwenden, um den Code für ältere Umgebungen kompatibel zu machen.
Das ist zwar mehr Arbeit als ein Promise
API Polyfill für pre-ES6 Promises, aber der Aufwand lohnt sich, weil Generatoren die asynchrone Ablaufsteuerung viel besser in einer vernünftigen, vernünftigen, synchron aussehenden, sequenziellen Weise ausdrücken können.
Wenn du einmal von den Generatoren begeistert bist, wirst du nie wieder in die Hölle der asynchronen Spaghetti-Callbacks zurückkehren wollen!
Überprüfung
Generatoren sind ein neuer ES6-Funktionstyp, der nicht wie normale Funktionen bis zur Fertigstellung läuft. Stattdessen kann der Generator mitten in der Ausführung angehalten werden (wobei sein Zustand vollständig erhalten bleibt) und kann später an der Stelle fortgesetzt werden, an der er aufgehört hat.
Dieser Austausch zwischen Pause und Wiederaufnahme ist kooperativ und nicht präventiv, d.h. der Generator hat die alleinige Möglichkeit, sich selbst mit dem Schlüsselwort yield
anzuhalten, und der Iterator, der den Generator steuert, hat die alleinige Möglichkeit (über next(..)
), den Generator wieder aufzunehmen.
Die yield
/ next(..)
Dualität ist nicht nur ein Kontrollmechanismus, sondern auch ein Mechanismus zur Weitergabe von Nachrichten in beide Richtungen. Ein yield ..
-Ausdruck wartet auf einen Wert und der nächste next(..)
-Aufruf gibt einen Wert (oder eine implizite undefined
) an den angehaltenen yield
-Ausdruck zurück.
Der Hauptvorteil von Generatoren in Bezug auf die asynchrone Ablaufsteuerung besteht darin, dass der Code innerhalb eines Generators eine Abfolge von Schritten für die Aufgabe in einer natürlich synchronen/sequenziellen Weise ausdrückt. Der Trick besteht darin, dass wir potenzielle Asynchronität hinter dem Schlüsselwort yield
verstecken und die Asynchronität in den Code verlagern, in dem der Iterator des Generators gesteuert wird.
Mit anderen Worten: Generatoren bewahren ein sequenzielles, synchrones, blockierendes Codemuster für asynchronen Code, wodurch unser Gehirn viel natürlicher über den Code nachdenken kann und einer der beiden Hauptnachteile von callback-basiertem asynchronem Code behoben wird.
Get Du kennst JS nicht: Asynchronität und Leistung now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.