JavaScript-Arrays in wenigen Worten abschließend zu beschreiben ist fast unmöglich, denn sie sind so vielseitig! Je nach Einsatzszenario fungieren sie als Stack (Methoden push()
und pop()
), als Tuple (mit fester Länge und gemischtem Inhalt) oder als das, was in anderen Programmiersprachen als „Array“ oder „List“ bezeichnet wird. Außerdem implementieren sie das Iterationsprotokoll, sind also kompatibel zu for-of
-Schleifen und vielen anderen nativen Sprachkonstrukten. Wenn wir nicht zu genau hinschauen und keine unangenehmen Fragen stellen, können wir uns vielleicht mit der Umschreibung „iterierbare Liste von Werten“ zufriedengeben.
Genau das Gleiche lässt sich über JavaScript-Generators sagen. Sie sind vergleichsweise neu, vergleichsweise selten und, genau wie Arrays, extrem vielseitig. Wenn wir nicht zu genau hinschauen und keine unangenehmen Fragen stellen, können wir uns auch für Generators mit der Umschreibung „iterierbare Liste von Werten“ zufriedengeben. Sobald wir uns auf diese Sichtweise einlassen, werden Generators zu einem schönen Werkzeug für den Alltag!
Generators sind Objekte, die Generator Functions entspringen, fast normalen function
-Functions mit einem Sternchen dahinter. In Generator Functions ist das yield
-Keyword verfügbar. Dieses fungiert ein wenig wie ein return
-Statement und gibt den Wert auf seiner rechten Seite heraus. Damit lässt sich eine Sequenz bzw. eine Liste formulieren, die in der Benutzung einem Array nicht unähnlich ist:
// Generator Function
function * genFn () {
yield 0;
yield 1;
yield 2;
}
// Generator
const numberGenerator = genFn();
// Generator wie ein Array verwenden
for (const number of numberGenerator) {
console.log(number); // 0, 1, 2
}
Ein Generator Function ist im Prinzip ein Template für eine Sequenz bzw. Liste. Die Schritte der Sequenz sind die Ausdrücke auf der rechten Seite der yield
-Statements, und die Liste ergibt sich, wenn die Schleife die Sequenz durchgeht und der Reihe nach 0, 1 und 2 generiert werden.
Das sieht auf den ersten Blick etwas exotisch aus, aber vergleichen wir das Ganze doch mal mit dem folgenden Code:
// Fast eine Generator Function
function genFn () {
const values = [];
values.push(0);
values.push(1);
values.push(2);
return values;
}
// Quasi ein Generator
const numbers = genFn();
// Array wie einen Generator verwenden
for (const number of numbers) {
console.log(number); // 0, 1, 2
}
Unter der JavaScript-Haube geht hier zwar etwas ganz anderes ab, als mit Generators, aber nicht vergessen: heute schauen wir nicht allzu genau hin und stellen keine unangenehmen Fragen. Wir erreichen mit beiden Codeschnipseln das gleiche Ziel einer Liste von Zahlen, wobei der Ansatz mit Generators einige Vorteile (oder zumindest interessante Tradeoffs) bietet.
Ein erster Vorteil ist, dass ein Generator immer eine Liste von Werten abbildet, zumindest bei unserer vereinfachten Betrachtungsweise. Das bedeutet, dass eine leere Liste besonders einfach zu bauen ist: wir machen einfach gar kein yield
:
// Nur Werte liefern, wenn "someCondition" true ist
function createArray () {
const values = [];
if (someCondition) {
values.push(0);
values.push(1);
values.push(2);
}
return values;
}
function * createGenerator () {
if (someCondition) {
yield 0;
yield 1;
yield 2;
}
}
Die Vereinfachung ist offensichtlich und selbsterklärend: wenn ein Generator implizit immer eine Liste liefert, brauchen wir, anders als bei der Array-Funktion, keine zu erstellen und zurückzugeben. Ein weiterer Bonus liegt darin, dass es, ausgehend von der Beschreibung des Problems im Kommentar, nur einen Weg gibt, createGenerator()
zu schreiben: wenn Bedingung, dann yield
Werte. Über die Formulierung von createArray()
könnte man hingegen streiten. Vielleicht wäre ein Early Return besser? Oder undefined
statt eines leeren Arrays? Bei Generators stellt sich diese Frage praktischerweise gar nicht.
Sollte ein Early Return gewünscht sein, geht der Vorteil des Generators dahin:
// Wenn "someCondition" true ist, Early Return durchführen
function createArray () {
if (someCondition) {
return [ 42 ]; // Ausgabe + Ende der Funktion
}
const values = [];
values.push(0);
values.push(1);
values.push(2);
return values;
}
function * createGenerator () {
if (someCondition) {
yield 42; // Ausgabe
return; // Ende der Funktion
}
yield 0;
yield 1;
yield 2;
}
Einerseits ist es bei createArray()
nötig, die zurückgegebene 42 händisch in ein Array zu verpacken, was beim Generator entfällt. Dieser benötigt dafür ein yield
und ein return
-Statement; return 42
ist zwar im Prinzip erlaubt, liefert aber die 42 nicht in der erzeugten Liste, sondern irgendwo im Nirvana ab.
Wiederum für Generators spricht, dass es neben dem normalen yield
noch eine Art Super-Yield gibt, das den Inhalt anderer Generators herausgibt:
function * createStrings () {
yield "A";
yield "B";
yield "C";
}
function * createValues () {
yield 0;
yield 1;
yield 2;
yield* createStrings();
}
for (const value of createValues()) {
console.log(value)
}
// > 0, 1, 2, "A", "B", "C"
Das yield*
in der Zeile yield* createStrings()
gibt anders als das normale yield
nicht einfach den Wert auf seiner rechten Seite (den Generator) aus, sondern das, was der Generator erzeugt! Und das klappt auch mit anderen iterierbaren Objekten, nicht nur mit Generatoren:
function * createValues () {
yield 0;
yield 1;
yield 2;
yield* [ "A", "B", "C" ]; // klappt auch mit Arrays!
}
for (const value of createValues()) {
console.log(value)
}
// > 0, 1, 2, "A", "B", "C"
yield*
fungiert also eine Art implizites flatMap()
für alles mögliche an listen-artigen Datenstrukturen, nicht nur für Generators. Das ist ein ziemlich mächtiges Feature! Die Bedingung hierfür ist allerdings, dass yield*
auch verwendet werden kann, was ausschließlich direkt in Generator Functions möglich ist. Callbacks sind also ein syntaktisches Problem:
function * createValues () {
yield 0;
setTimeout( () => {
yield 1; // > SyntaxError
}, 1000);
yield 2;
}
Mit Arrays würde der Code zwar nicht mit einem Error abbrechen, sondern „funktionieren“ …
function createValues () {
const values = [];
values.push(0);
setTimeout( () => {
values.push(1);
}, 1000);
values.push(2);
return values;
}
…aber trotzdem ein großes Problem darstellen! Immerhin wird hier ein Array aus der Funktion ausgegeben, in das nach seiner Ausgabe noch zusätzliche Werte hinterlegt werden – ein sicheres Rezept für Bugs und Verwirrung. An sich ist aber das Konzept einer Liste, in der sich erst nach und nach Werte einfinden, durchaus sinnvoll und wird z.B. durch die Observables in RxJS umgesetzt. Mit Arrays lässt sich dieses Konzept, wie wir gesehen haben, nicht wirklich abbilden und ein Timeout-Callback in einem Generator ist, wie wir ebenfalls gesehen haben, syntaktisch nicht mit yield
kombinierbar. Das bedeutet aber nicht, dass Generators und setTimeout()
nicht wunderbar zusammenpassen würden – der Timeout darf sich nur nicht im Generator befinden, sondern muss nach außen verfrachtet werden.
Wir können Generators unter gewissen Bedingungen mit Arrays vergleichen, aber es gibt einen wichtigen Unterschied: Generators sind lazy und liefern nur dann Werte, wenn Werte angefordert werden. Dies geschieht mit der Generator-Methode next()
:
function * createGenerator () {
yield 0; // landet in a
yield 1; // landet in b
yield 2; // wird nie angefordert
}
const generator = createGenerator();
const a = generator.next(); // { value: 0, done: false }
const b = generator.next(); // { value: 1, done: false }
Jeder Aufruf von next()
liefert ein Objekt, in dem das Feld value
den Wert rechts des letzten yield
-Statements enthält und in dem done
nur true ist, wenn der Generator abgearbeitet wurde bzw. ein return
-Statement erreicht wurde. In obigen Beispiel finden nur zwei Aufrufe von next()
statt, was bedeutet, dass das yield 2
in der Generator Function nie erreicht wird und nie eine 2
in die Welt gesetzt wird. Bei einer Funktion, die ein Array liefert, sähe das anders aus:
function createArray () {
const values = [];
values.push(0); // landet in a
values.push(1); // landet in b
values.push(2); // braucht niemand, ist trotzdem im Array
return values;
}
const list = createArray();
const a = list[0]; // 0
const b = list[1]; // 1
Auch wenn der dritte Wert nie verwendet wird, so landet er doch im Array, unmittelbar beim Aufruf der Funktion. Es ist erwähnenswert, dass Arrays zwar ganz anders funktionieren als Generators, aber auch eine Generators entsprechende API anbieten:
const gen = function * () {
yield 23;
yield 42;
}();
gen.next(); // > { value: 23, done: false }
gen.next(); // > { value: 42, done: false }
const arr = [ 23, 42 ];
const arrIter = arr[Symbol.iterator]();
arrIter.next(); // > { value: 23, done: false }
arrIter.next(); // > { value: 42, done: false }
Im speziellen Feld Symbol.iterator
verstecken Arrays eine Funktion, die einen Iterator für das betroffene Array bereitstellt. Dieser Iterator unterstützt ebenso wie ein Generator die next()
-Methode, weswegen for-of
-Schleifen beide Objekt-Typen gleich unterstützen. Der Unterschied bleibt aber bestehen: während der Generator Werte erst generiert, sind sie beim Array von Anfang an vorhanden und binden entsprechend Ressourcen.
Wir halten fest: sowohl Arrays als auch Generators bilden Listen von Werten ab und beide unterstützen das Iterator-Protokoll mit der next()
-Methode. Dank des Iterator-Protokolls können Arrays wie Generators von for-of
-Schleifen konsumiert werden, von Array.from()
in (neue) Arrays überführt werden oder manuell via next()
iteriert werden. Der große Unterschied ist zwischen Arrays und Generator ist, dass letztere lazy sind und daher immer nur den Code bis zum nächsten yield
ausführen. Der Unterschied zwischen for-of
bzw. Array.from()
und der next()
-Methode ist, dass letztere (da sie ja nur eine Funktion ist) in Callbacks gesteckt und damit asynchron verarbeitet werden kann. Daraus folgt: setTimeout()
und Generators können sehr wohl zusammen verwendet werden, der Timeout muss nur außerhalb der Funktion stattfinden:
function * createGenerator () {
yield 0;
yield 1;
yield 2;
}
const generator = createGenerator();
setTimeout( function consumeValue () {
const next = generator.next();
if (!next.done) {
console.log(next.value);
setTimeout(consumeValue, 1000);
}
}, 1000);
Dieser Aufruf schreibt nicht nur mit einer Sekunde Abstand Abstand 0, 1 und 2 in die Konsole, sondern erzeugt die Werte auch jeweils erst nach einer Sekunde. Dadurch haben wir die eigentliche Erzeugung der Werte vom Timing für die Erzeugung der Werte entkoppelt und Interleaving etabliert. Zwischen der Erzeugung der einzelnen Werte erhält die JS-Engine dank setTimeout()
eine Atempause, um andere Aufgaben abzuwickeln, Frames zu rendern und sonstige dringende Angelegenheiten zu erledigen. Da wir unsere Werte in einem Generator erzeugen, brauchen wir die Generator Function selbst hierfür nicht zu verbiegen, sondern überlassem die Timing-Frage denen, die den Generator nutzen. Jeder Konsument eines Generators kann selbst über das genehme Timing entscheiden:
function * createGenerator () {
yield 0;
yield 1;
yield 2;
}
// Zeitgesteuert?
const gen1 = createGenerator();
setTimeout( function consumeValue () {
const next = gen1.next();
if (!next.done) {
console.log(next.value);
setTimeout(consumeValue, 1000);
}
}, 1000); // 0, 1 und 2 nach je einer Sekunde
// Abhängig von der Framerate?
const gen2 = createGenerator();
requestAnimationFrame( function consumeValue () {
const next = gen1.next();
if (!next.done) {
console.log(next.value);
requestAnimationFrame(consumeValue);
}
}); // 0, 1 und 2 nach je einem Frame
// Alles auf einmal, ohne Pausen?
for (const value of createGenerator()) {
console.log(value);
}
// Oder noch einfacher
const allValues = Array.from(createGenerator());
Es gibt mehrere Gründe dafür, Timing-Fragen von der eigentlichen Logik zu entkoppeln. Zum einen ist es oft wünschenswert, lang laufende und komplexe JS-Funktionen durch Timeout-Atempausen für Browser besser verdaulich zu machen. Wer (aus welchen Gründen auch immer) 1000 Reflows zu triggern gedenkt, ist gut damit beraten, diese nicht auf einmal durchzuführen, sondern zeitgesteuert zu batchen. Somit hat der Browser zwischen den Rechenaufgaben Zeit die Webseite zu rendern und die Framerate bleibt im grünen Bereich. Allerdings ist es bei ohnehin schon komplizierten Funktionen aus Entwicklersicht wünschenswert, wenn diese Funktionen nicht auch noch durch Timeout-Callbacks weiter verkompliziert würden. Generator Functions sind hier die beste Lösung: einfach zunächst die (Generator-) Funktion schreiben, als gäbe es die Timeout-Überlegungen gar nicht und hinterher ein setTimeout()
anflanschen – so bleibt sowohl die Erzeugung der Werte als auch das Timing der Erzeugung übersichtlich und beherrschbar, bei gleichzeitig flotter Framerate.
Aber brauchen wir wirklich immer eine flotte Framerate? Für einen Unit Test ist das eher unwichtig, denn hier kommt es nur auf die Ergebnisse an. Zum Glück ist es den Konsumenten von Generators selbst überlassen, wie sie einen Generator zu konsumieren gedenken. Für einen Test könnte das synchron erfolgen, für Production mit framerate-schonenden Timeout-Päuschen:
function * createGenerator () {
yield 0;
yield 1;
yield 2;
}
// Für Production: mit Timeout-Interleaving und Callbacks
consume(createGenerator(), (values) => {
// values = 0, 1, 2
});
// Für den Test: alles auf einmal bitte!
expect(Array.from(createGenerator())).toBe([ 0, 1, 2 ]);
Ob lang laufende und komplexe JS-Funktionen wirklich Timeout-Atempausen brauchen, kommt oft mehr auf den Kontext des Aufrufs an als auf die Funktion selbst.
Ein drittes schönes Feature von Generators ist der eingebaute Abbruch-Mechanismus. Normale JavaScript-Funktionen laufen nach ihrem Aufruf bis zum Ende durch, falls nicht Exceptions dazwischenfunken. Ein Abbrechen einer laufenden Operation von Außen gibt es eigentlich gar nicht … abgesehen von Generator Functions! Die Operationen in einem Generator finden nur statt, solange Werte angefordert werden. Wird nichts mehr angefordert, passiert nichts mehr; die Funktion ist effektiv gestoppt.
const createGenerator = function * () {
let i = 0;
while (true) {
yield i++;
}
};
let stop = false;
const gen = createGenerator();
setTimeout( function consumeValue () {
const next = gen.next();
if (!stop) {
console.log(next.value);
setTimeout(consumeValue, 1000);
}
}, 1000); // jede Sekunde eine Zahl bis "stop" true ist
stopButton.addEventListener("click", () =>{
stop = true;
}, { once: true });
Durch das hin- und herschalten zwischen verschiedenen Generators lassen sich auch Konzepte wie Restarts recht bequem umsetzen.
Ich schraube gerade an der Tester-Library von Warhol, die die Aufgabe hat, Designfehler auf Websites zu finden. Diese Library wird in unserer Browser-Extension verwendet, aber auch in unseren automatisierten Services, die mit ferngesteuerten Browsern automatisch ganze Domains nach Fehlern abgrasen. Dank der Vielfalt der Einsatzgebiete findet sich die Library mit zahlreichen konkurrierenden Ansprüchen konfrontiert:
- Die Tests müssen funktionieren und weder falsch positive noch falsch negative Ergebnisse sind akzeptabel. Also haben wir hunderte von automatisierten Tests und es werden stetig mehr. Diese müssen so schnell laufen wie möglich, denn Entwickler-Zeit ist kostbar.
- Innerhalb der Browser-Extension müssen die Tests vor den Augen eines ungeduldigen Menschen durchgeführt werden. „Dank“ moderner Webentwicklungs-Praktiken wir es hierbei oft mit absurd großen DOM-Bäumen zu tun, die rekursiv nach Fehlern abgegrast werden müssen. Ein Fortschrittsbaken könnte die wahrgenommene Performance stark verbessern und ein Stopp-Button, den die Nutzer nach den ersten 9000 gefundenen Fehlern drücken können, trägt auch zum Erhalt der Laune bei.
- Die gleichen Tests in automatisierten Services brauchen keine ungeduldigen Nutzer zu bedenken, sondern müssen einfach nur so schnell sein wie möglich. Jedes bisschen Cloud-Zeit kostet schließlich!
- Die Tester-Library ist ein ausgesprochen kompliziertes Stück TypeScript-Hexerei und darf nicht unübersichtlicher werden als absolut nötig.
Ursprünglich war die Tester-Library ungefähr wie folgt aufgebaut:
// Alte Vesion (extrem vereinfachte Darstellung)
const test = (element: Element): TestResult[] => {
return [
testElement(element),
...element.children.map( (child) => test(child) ),
];
};
Der ganze DOM-Tree wurde durchgetestet und am Ende gibt es ein Array mit Ergebnissen. Es gab kein Interleaving, keine Abbruch-Möglichkeit und in der Realität stellte sich das Handling der Ergebnis-Arrays oft weniger einfach dar wie hier gezeigt. Oft gibt es Early Returns, im Voraus bekannte Ergebnisse und viele weitere Komplikationen. Mit Warhols zunehmenden Fähigkeiten und den immer komplexeren Enterprise-Webseiten, auf denen Warhol meist zum Einsatz kommt, wurde die Performance-Frage akut. Und was haben wir getan?
// Neue Version (extrem vereinfachte Darstellung)
const test = (element: Element): Generator<TestResult, void, void> => {
yield testElement(element);
for (const child of element.children) {
yield * test(child);
}
};
Wir haben eigentlich fast gar nichts getan! Die array-produzierenden Funktionen mussten zu Generator Functions umgemodelt werden (was bei 90% der Funktionen komplett ohne Nachdenken ging) und schon sind alle Anforderungen bedient:
- Automatische Tests konsumieren die Generators synchron und sind damit so schnell wie eh und je.
- In der Browser-Extension macht Interleaving mit
requestAnimationFrame()
der Rendern einer Lade-Animation möglich und ein Abbruch-Button war trivial zu implementieren.
- In der Cloud verzichten wir auf Interleaving und blockieren einfach mit den Tests das UI komplett, denn es gibt ja keinen Menschen, der sich daran stört.
- Der Code für die Tester-Library ist praktisch gleich geblieben, Arrays wurde fast überall 1:1 mit Generators ersetzt.
Generators in JavaScript sind sehr seltsame, sehr vielseitige Objekte. Arrays und normale JS-Objekte sind auch sehr vielseitig, aber so konkret, dass man als Entwickler recht schnell etwas damit anzufangen weiß. Die Vielseitigkeit von Generators ist anderer Natur: sie sind so vielseitig, weil sie unglaublich allgemein sind, aber das macht auch schwer, sie überhaupt als etwas zu erkennen, das eine Lösung für ein konkretes Problem sein kann. In solchen Fällen ist es wichtig, Dinge durch bestimmte Linsen zu betrachten – in meinem Fall mit dem Warhol-Tester war es die Listen-Linse, aber durch andere Linsen sind Generators genau das richtige Mittel um Message Passing oder Async/Await zu implementieren. Für das initiale Verständnis ist es dabei immer gut, nicht zu genau hinzuschauen, keine unangenehmen Fragen zu stellen und sich nicht zu sehr im Abstrakten zu verlieren.