JavaScript für ungeduldige Programmierer (ES2022-Ausgabe)
Bitte unterstützen Sie dieses Buch: kaufen Sie es oder spenden Sie
(Werbung, bitte nicht blockieren.)

38 Synchrone Generatoren (Fortgeschritten)



38.1 Was sind synchrone Generatoren?

Synchrone Generatoren sind spezielle Versionen von Funktions- und Methodendefinitionen, die immer synchrone Iterables zurückgeben.

// Generator function declaration
function* genFunc1() { /*···*/ }

// Generator function expression
const genFunc2 = function* () { /*···*/ };

// Generator method definition in an object literal
const obj = {
  * generatorMethod() {
    // ···
  }
};

// Generator method definition in a class definition
// (class declaration or class expression)
class MyClass {
  * generatorMethod() {
    // ···
  }
}

Ein Sternchen (*) kennzeichnet Funktionen und Methoden als Generatoren.

38.1.1 Generatorfunktionen geben Iterables zurück und füllen sie über yield

Wenn wir eine Generatorfunktion aufrufen, gibt sie ein Iterable zurück (genauer gesagt: einen Iterator, der auch selbst ein Iterable ist). Der Generator füllt dieses Iterable über den yield-Operator.

function* genFunc1() {
  yield 'a';
  yield 'b';
}

const iterable = genFunc1();
// Convert the iterable to an Array, to check what’s inside:
assert.deepEqual(
  Array.from(iterable), ['a', 'b']
);

// We can also use a for-of loop
for (const x of genFunc1()) {
  console.log(x);
}
// Output:
// 'a'
// 'b'

38.1.2 yield pausiert eine Generatorfunktion

Die Verwendung einer Generatorfunktion umfasst die folgenden Schritte:

Daher tut yield mehr, als nur Werte zu Iterables hinzuzufügen – es pausiert auch und verlässt die Generatorfunktion.

Betrachten wir dies anhand der folgenden Generatorfunktion.

let location = 0;
function* genFunc2() {
  location = 1; yield 'a';
  location = 2; yield 'b';
  location = 3;
}

Um genFunc2() zu verwenden, müssen wir zuerst den Iterator/Iterable iter erstellen. genFunc2() ist nun „vor“ seinem Körper pausiert.

const iter = genFunc2();
// genFunc2() is now paused “before” its body:
assert.equal(location, 0);

iter implementiert das Iterationsprotokoll. Daher steuern wir die Ausführung von genFunc2() über iter.next(). Das Aufrufen dieser Methode setzt das pausierte genFunc2() fort und führt es aus, bis ein yield erreicht wird. Dann pausiert die Ausführung und .next() gibt den Operanden des yield zurück.

assert.deepEqual(
  iter.next(), {value: 'a', done: false});
// genFunc2() is now paused directly after the first `yield`:
assert.equal(location, 1);

Beachten Sie, dass der gewertete Wert 'a' in einem Objekt verpackt ist, was die Art und Weise ist, wie Iteratoren ihre Werte immer liefern.

Wir rufen iter.next() erneut auf, und die Ausführung wird dort fortgesetzt, wo wir zuvor pausiert hatten. Sobald wir auf das zweite yield stoßen, wird genFunc2() pausiert und .next() gibt den gewerteten Wert 'b' zurück.

assert.deepEqual(
  iter.next(), {value: 'b', done: false});
// genFunc2() is now paused directly after the second `yield`:
assert.equal(location, 2);

Wir rufen iter.next() ein weiteres Mal auf, und die Ausführung wird fortgesetzt, bis sie den Körper von genFunc2() verlässt.

assert.deepEqual(
  iter.next(), {value: undefined, done: true});
// We have reached the end of genFunc2():
assert.equal(location, 3);

Diesmal ist die Eigenschaft .done des Ergebnisses von .next() true, was bedeutet, dass der Iterator beendet ist.

38.1.3 Warum pausiert yield die Ausführung?

Welche Vorteile hat es, dass yield die Ausführung pausiert? Warum funktioniert es nicht einfach wie die Array-Methode .push() und füllt das Iterable mit Werten, ohne zu pausieren?

Durch das Pausieren bieten Generatoren viele Features von Coroutinen (stellen Sie sich Prozesse vor, die kooperativ multitasken). Wenn wir beispielsweise nach dem nächsten Wert eines Iterables fragen, wird dieser Wert lazy (bei Bedarf) berechnet. Die folgenden beiden Generatorfunktionen demonstrieren, was das bedeutet.

/**
 * Returns an iterable over lines
 */
function* genLines() {
  yield 'A line';
  yield 'Another line';
  yield 'Last line';
}

/**
 * Input: iterable over lines
 * Output: iterable over numbered lines
 */
function* numberLines(lineIterable) {
  let lineNumber = 1;
  for (const line of lineIterable) { // input
    yield lineNumber + ': ' + line; // output
    lineNumber++;
  }
}

Beachten Sie, dass das yield in numberLines() innerhalb einer for-of-Schleife vorkommt. yield kann in Schleifen verwendet werden, aber nicht in Callbacks (mehr dazu später).

Kombinieren wir beide Generatoren, um das Iterable numberedLines zu erzeugen.

const numberedLines = numberLines(genLines());
assert.deepEqual(
  numberedLines.next(), {value: '1: A line', done: false});
assert.deepEqual(
  numberedLines.next(), {value: '2: Another line', done: false});

Der Hauptvorteil der Verwendung von Generatoren ist hier, dass alles inkrementell funktioniert: Über numberedLines.next() fragen wir numberLines() nach nur einer nummerierten Zeile. Diese wiederum fragt genLines() nach nur einer unnummerierten Zeile.

Dieser Inkrementalismus funktioniert auch dann weiter, wenn beispielsweise genLines() seine Zeilen aus einer großen Textdatei liest: Wenn wir numberLines() nach einer nummerierten Zeile fragen, erhalten wir eine, sobald genLines() seine erste Zeile aus der Textdatei gelesen hat.

Ohne Generatoren würde genLines() zuerst alle Zeilen lesen und sie zurückgeben. Dann würde numberLines() alle Zeilen nummerieren und sie zurückgeben. Wir müssen daher viel länger warten, bis wir die erste nummerierte Zeile erhalten.

  Übung: Eine normale Funktion in einen Generator umwandeln

exercises/sync-generators/fib_seq_test.mjs

38.1.4 Beispiel: Mapping über Iterables

Die folgende Funktion mapIter() ähnelt der Array-Methode .map(), gibt aber ein Iterable zurück, kein Array, und erzeugt seine Ergebnisse bei Bedarf.

function* mapIter(iterable, func) {
  let index = 0;
  for (const x of iterable) {
    yield func(x, index);
    index++;
  }
}

const iterable = mapIter(['a', 'b'], x => x + x);
assert.deepEqual(
  Array.from(iterable), ['aa', 'bb']
);

  Übung: Iterables filtern

exercises/sync-generators/filter_iter_gen_test.mjs

38.2 Generatoren aus Generatoren aufrufen (Fortgeschritten)

38.2.1 Generatoren über yield* aufrufen

yield funktioniert nur direkt innerhalb von Generatoren – bisher haben wir keine Möglichkeit gesehen, das Yielding an eine andere Funktion oder Methode zu delegieren.

Betrachten wir zunächst, was nicht funktioniert: Im folgenden Beispiel möchten wir, dass foo() bar() aufruft, damit letzteres zwei Werte für ersteres yielded. Leider schlägt ein naiver Ansatz fehl.

function* bar() {
  yield 'a';
  yield 'b';
}
function* foo() {
  // Nothing happens if we call `bar()`:
  bar();
}
assert.deepEqual(
  Array.from(foo()), []
);

Warum funktioniert das nicht? Der Funktionsaufruf bar() gibt ein Iterable zurück, das wir ignorieren.

Was wir wollen, ist, dass foo() alles yielded, was bar() yielded. Das ist es, was der yield*-Operator tut.

function* bar() {
  yield 'a';
  yield 'b';
}
function* foo() {
  yield* bar();
}
assert.deepEqual(
  Array.from(foo()), ['a', 'b']
);

Mit anderen Worten, das vorherige foo() ist ungefähr gleichbedeutend mit:

function* foo() {
  for (const x of bar()) {
    yield x;
  }
}

Beachten Sie, dass yield* mit jedem Iterable funktioniert.

function* gen() {
  yield* [1, 2];
}
assert.deepEqual(
  Array.from(gen()), [1, 2]
);

38.2.2 Beispiel: Iterieren über einen Baum

yield* ermöglicht uns rekursive Aufrufe in Generatoren, was beim Iterieren über rekursive Datenstrukturen wie Bäume nützlich ist. Nehmen Sie zum Beispiel die folgende Datenstruktur für binäre Bäume.

class BinaryTree {
  constructor(value, left=null, right=null) {
    this.value = value;
    this.left = left;
    this.right = right;
  }

  /** Prefix iteration: parent before children */
  * [Symbol.iterator]() {
    yield this.value;
    if (this.left) {
      // Same as yield* this.left[Symbol.iterator]()
      yield* this.left;
    }
    if (this.right) {
      yield* this.right;
    }
  }
}

Die Methode [Symbol.iterator]() fügt Unterstützung für das Iterationsprotokoll hinzu, was bedeutet, dass wir eine for-of-Schleife verwenden können, um über eine Instanz von BinaryTree zu iterieren.

const tree = new BinaryTree('a',
  new BinaryTree('b',
    new BinaryTree('c'),
    new BinaryTree('d')),
  new BinaryTree('e'));

for (const x of tree) {
  console.log(x);
}
// Output:
// 'a'
// 'b'
// 'c'
// 'd'
// 'e'

  Übung: Über verschachtelte Arrays iterieren

exercises/sync-generators/iter_nested_arrays_test.mjs

38.3 Hintergrund: Externe vs. interne Iteration

Als Vorbereitung auf den nächsten Abschnitt müssen wir zwei verschiedene Stile des Iterierens über die Werte „innerhalb“ eines Objekts lernen.

Der nächste Abschnitt enthält Beispiele für beide Iterationsstile.

38.4 Anwendungsfall für Generatoren: Wiederverwendung von Traversierungen

Ein wichtiger Anwendungsfall für Generatoren ist das Extrahieren und Wiederverwenden von Traversierungen.

38.4.1 Die wiederzuverwendende Traversierung

Betrachten wir als Beispiel die folgende Funktion, die einen Dateibaum durchläuft und seine Pfade protokolliert (sie verwendet die Node.js API dafür).

function logPaths(dir) {
  for (const fileName of fs.readdirSync(dir)) {
    const filePath = path.resolve(dir, fileName);
    console.log(filePath);
    const stats = fs.statSync(filePath);
    if (stats.isDirectory()) {
      logPaths(filePath); // recursive call
    }
  }
}

Betrachten Sie das folgende Verzeichnis.

mydir/
    a.txt
    b.txt
    subdir/
        c.txt

Protokollieren wir die Pfade innerhalb von mydir/.

logPaths('mydir');

// Output:
// 'mydir/a.txt'
// 'mydir/b.txt'
// 'mydir/subdir'
// 'mydir/subdir/c.txt'

Wie können wir diese Traversierung wiederverwenden und etwas anderes tun, als die Pfade zu protokollieren?

38.4.2 Interne Iteration (Push)

Eine Möglichkeit, Traversierungscode wiederzuverwenden, ist über interne Iteration: Jeder traversierte Wert wird an einen Callback übergeben (Zeile A).

function visitPaths(dir, callback) {
  for (const fileName of fs.readdirSync(dir)) {
    const filePath = path.resolve(dir, fileName);
    callback(filePath); // (A)
    const stats = fs.statSync(filePath);
    if (stats.isDirectory()) {
      visitPaths(filePath, callback);
    }
  }
}
const paths = [];
visitPaths('mydir', p => paths.push(p));
assert.deepEqual(
  paths,
  [
    'mydir/a.txt',
    'mydir/b.txt',
    'mydir/subdir',
    'mydir/subdir/c.txt',
  ]);

38.4.3 Externe Iteration (Pull)

Eine andere Möglichkeit, Traversierungscode wiederzuverwenden, ist über externe Iteration: Wir können einen Generator schreiben, der alle traversierten Werte yieldet (Zeile A).

function* iterPaths(dir) {
  for (const fileName of fs.readdirSync(dir)) {
    const filePath = path.resolve(dir, fileName);
    yield filePath; // (A)
    const stats = fs.statSync(filePath);
    if (stats.isDirectory()) {
      yield* iterPaths(filePath);
    }
  }
}
const paths = Array.from(iterPaths('mydir'));

38.5 Fortgeschrittene Features von Generatoren

Das Kapitel über Generatoren in Exploring ES6 behandelt zwei Themen, die über den Rahmen dieses Buches hinausgehen.