Generatoren in JavaScript: Erste Schritte

Seit ECMAScript 2015 (es6) stehen Dir in JavaScript Generatoren und Generatorfunktionen zur Verfügung. Generatoren sind Funktionen, deren Ausführung unterbrochen und zu einem beliebigen späteren Zeitpunkt wieder fortgesetzt werden kann. Dabei bleibt der Zustand des Generators, das heißt seine Argumente und lokalen Variablen, über Unterbrechungen hinweg erhalten.

Einen Generator erzeugst Du mit Hilfe einer Generatorfunktion. Dafür wurde die JavaScript Syntax mit Version es6 erweitert um function*() und *(). Mit function*() definierst Du eine Generatorfunktion

const simpleGenerator = function*() {
  //…
};

*() dagegen dient der Definition von Generatormethoden.

class FancyClass {
  *iterate() {
    // …
  }
}

Der Inhalt der Generatorfunktion bzw. -methode definiert das Verhalten des Generators. Mit den ebenfalls in es6 neu eingeführten Schlüsselwörtern yield und yield* definierst Du die Stellen, an denen die Ausführung des Generators unterbrochen wird und Werte an den Aufrufer zurückgegeben werden sollen.

const simpleGenerator = function*() {
  yield 5;
  yield 10;
  return 20;
}

Generatoren als Iteratoren

Die Hauptanwendung von Generatoren ist sicherlich die Verwendung als Iterator. Zum Beispiel in einer for…of Schleife:

for (let i of simpleGenerator()) {
  console.log(i);
}
//> 5
//> 10

Dir fiel sicher sofort auf, dass hier in der Ausgabe die 20 fehlt. Das ist kein Fehler. Bei der Verwendung als Iterator werden Werte, die der Generator über return zurückgibt, nicht berücksichtigt. Das Gleiche passiert, wenn Du die Werte über den Spread-Operator in einem Array ablegst.

[...simpleGenerator()]
//> [5, 10]

Anwendungsbeispiele

range(start, end, step = 1)

Mit einem range-Generator kannst Du traditionelle for-Schleifen in for-of Schleifen umwandeln.

const range = function*(start, end, step = 1) {
  for (let i = start; i < end; i += step) {
    yield i;
  }
};

for (let i of range(0, 5)) {
  // …
}

Besonders schick sieht das Initialisieren von Arrays mit dem range Generator aus:

[...range(0, 5)]
//> [0, 1, 2, 3, 4]

[...range(0, 15, 3)]
//> [0, 3, 6, 9, 12]

Listen filtern

Als Iterator über existierende Listen durchläuft der folgende Iterator nur jedes zweite Element der Liste, ohne die Liste zu duplizieren:

const everyOther = function*(list) {
  for (let i of range(1, list.length, 2)) {
    yield list[i];
  }
};

const list = [1, 2, 3, 4, 5];
[...everyOther(list)];
//> [2, 4]

Bemerkenswert an diesem Beispiel ist, dass das Array list vom Generator nie kopiert wird.

Über rekursive Datenstrukturen iterieren

Mit dem zu Beginn des Artikels bereits erwähnten Schlüsselwort yield* kannst Du das Erzeugen von Werten an einen anderen Generator oder an einen Iterator delegieren. Ein einfaches Beispiel:

const delegate = function*() {
  const values = [5, 10, 20];
  yield* values;
}

[...delegate()];
//> [5, 10, 20]

const anotherDelegate = function*() {
  yield* delegate();
  yield 30;
}

[...anotherDelegate()];
//> [5, 10, 20, 30]

Diese Beispiele sind jetzt nicht sonderlich sinnvoll. Aber sie sollten ausreichen, um eine grobe Idee davon zu bekommen, was yield* tut. Sehr hilfreich ist yield* bei rekursiven Datenstrukturen. Lass uns einen Blick auf die folgende Baum-Datenstruktur werfen:

class Tree {
  constructor(value, children = []) {
    this.value = value;
    this.children = children;
  }

  visit(callback) {
    callback(this.value);
    for (const child of this.children) {
      child.visit(callback);
    }
  }
}

const tree = new Tree(1, [new Tree(2), new Tree(3, [new Tree(4)])]);

Um alle Knoten dieses Baums zu durchlaufen, rufst Du tree.visit() mit einem Callback auf:

let values = [];
tree.visit(value => values.push(value));
console.log(values);
//> [1, 2, 3, 4]

Mit einem Generator aber durchläufst Du den Baum auch ohne Callback:

class Tree {
  // constructor wie oben

  *iterate() {
    yield this.value;
    for (const child of this.children) {
      yield* child.iterate();
    }
  }
}

const tree = new Tree(1, [new Tree(2), new Tree(3, [new Tree(4)])]);
[...tree.iterate()]
//> [1, 2, 3, 4]

Vorteile von Generatoren

Genau wie bei dem range Beispiel weiter oben sieht das iterative Durchlaufen eines Baumes wenig spektakulär aus. Im Gegenteil, es gibt bei beiden Beispielen weder funktional noch hinsichtlich des Ressourcenverbrauchs einen spürbaren Unterschied zwischen der klassischen Lösung und der Lösung mit den Generatoren. Wozu also überhaupt Generatoren verwenden an diesen Stellen?

Weil Du die Ausführung von Generatoren, wie zu Beginn schon erwähnt, jederzeit unterbrechen kannst. Verwendest Du Generatoren als Iteratoren, dann wird diese Eigenschaft von der JavaScript Syntax gut verborgen. Aber schauen wir uns das Beispiel mit dem einfachen Beispiel vom Anfang nochmal an:

const simpleGenerator = function*() {
  yield* [5, 10, 21];
}

Generator.next()

Wenn Du diese Generatorfunktion aufrufst und das Ergebnis speicherst, dann erhältst Du einen Generator.

const generator = simpleGenerator();

Dieser Generator besitzt, unter anderem, eine Methode next(). Bei einem frisch erzeugten Generator führt next() den Generator aus, bis er auf das erste yield oder yield* trifft:

generator.next();
//> {value: 5, done: false}

Der Rückgabewert ist ein Objekt mit zwei Eigenschaften. Die Eigenschaft value besitzt den Wert, der an yield übergeben wurde. Mit done zeigt der Generator an, ob er noch mehr Werte besitzt, die er zurückliefern kann. Erneutes Ausführen von next() setzt den Generator an genau der Stelle fort, an dem das letzte yield ihn gestoppt hat:

generator.next();
//> {value: 10, done: false}
generator.next();
//> {value: 21, done: false}
generator.next();
//> {value: undefined, done: true}
generator.next();
//> {value: undefined, done: true}

Du kannst next() beliebig oft aufrufen. Aber sobald der Generator keine Werte mehr hat, die er zurückgeben kann, erhältst Du für value immer undefined und die Eigenschaft done ist ab dann gleich true. Das heißt, wenn Du einen Generator in eine for-of Schleife steckst, dann passiert genau genommen das hier:

let generator = tree.iterate();
let iteration = generator.next();

while(!iteration.done) {
    // …
    iteration = generator.next();
}

Inversion of Control

Hier siehst Du jetzt auch den entscheidenden Unterschied zwischen dem rekursiven Ablaufen des Baumes und dem iterativen Durchlaufen mit einem Generator. Während beim rekursiven Ansatz die visit() Methode des Baumes ununterbrochen läuft und nur gelegentlich die Kontrolle an einen externen Callback übergibt, läuft beim iterativen Ansatz mit Generatoren externer Code, der hin und wieder die Kontrolle an den Baum übergibt.

Diesen externen Code kannst Du auch so gestalten, dass er längere Pausen macht:

function iterateWithPauses(generator) {
    const iteration = generator.next();

    if (!iteration.done) {
        console.log(iteration.value);
        setTimeout(() => iterateWithPauses(generator), 1000);
    }
}

iterateWithPauses(tree.iterate());

Damit ist es Dir möglich, den Baum iterativ zu Durchlaufen und regelmäßig Pausen einzulegen. Zum Beispiel um dem UI Zeit zu geben, sich zu aktualisieren. Das heißt, auch bei großen Bäumen mit mehreren Millionen Einträgen oder Generatoren, die aufwändigere Berechnungen erledigen als eine Eigenschaft auszulesen und ihren Wert zurückzugeben, bleibt das UI responsiv und flüssig. So etwas ist mit der Callback Methode nur schwer zu realisieren.

Weiterführende Informationen

Mozilla Developer Network

Michael Gerhäuser
Letzte Artikel von Michael Gerhäuser (Alle anzeigen)