24 Generatoren und Iteratoren

Generatoren und Iteratoren sind leistungsstarke Konzepte in JavaScript, die mit ECMAScript 2015 (ES6) eingeführt wurden. Sie bieten elegante Lösungen für die sequentielle Verarbeitung von Daten und ermöglichen neue Muster für asynchrone Programmierung. In diesem Abschnitt werden wir beide Konzepte im Detail untersuchen und ihre praktischen Anwendungen kennenlernen.

24.1 Iteratoren

Ein Iterator ist ein Objekt, das das Iteration Protocol in JavaScript implementiert und eine standardisierte Möglichkeit bietet, sequentiell auf Elemente einer Sammlung zuzugreifen.

24.1.1 Das Iteration Protocol

Das Iteration Protocol besteht aus zwei Teilen:

  1. Iterable Protocol: Definiert, wie ein JavaScript-Objekt seine Iteration verhalten festlegt. Ein Objekt ist ein Iterable, wenn es eine Methode mit dem Schlüssel Symbol.iterator implementiert, die einen Iterator zurückgibt.

  2. Iterator Protocol: Definiert eine standardisierte Methode, um sequentielle Werte zu produzieren. Ein Objekt ist ein Iterator, wenn es eine next()-Methode besitzt, die ein Objekt mit den Eigenschaften value und done zurückgibt.

// Ein einfacher Iterator
function createIterator(array) {
  let index = 0;
  
  return {
    next: function() {
      if (index < array.length) {
        return { value: array[index++], done: false };
      } else {
        return { done: true };
      }
    }
  };
}

const iterator = createIterator([1, 2, 3]);
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { done: true }

24.1.2 Integrierte Iterables

JavaScript bietet viele eingebaute Datenstrukturen, die bereits das Iterable-Protocol implementieren:

// Array als Iterable
const array = ['a', 'b', 'c'];
for (const element of array) {
  console.log(element); // 'a', 'b', 'c'
}

// String als Iterable
const string = "hello";
for (const char of string) {
  console.log(char); // 'h', 'e', 'l', 'l', 'o'
}

// Map als Iterable
const map = new Map([
  ['name', 'Max'],
  ['age', 30]
]);
for (const [key, value] of map) {
  console.log(`${key}: ${value}`); // 'name: Max', 'age: 30'
}

// Set als Iterable
const set = new Set([1, 2, 3, 2, 1]);
for (const num of set) {
  console.log(num); // 1, 2, 3
}

24.1.3 Benutzerdefinierte Iterables

Wir können auch eigene Iterables erstellen, indem wir die Symbol.iterator-Methode implementieren:

// Ein benutzerdefiniertes Iterable
const customIterable = {
  data: [10, 20, 30],
  
  // Symbol.iterator-Methode, die einen Iterator zurückgibt
  [Symbol.iterator]() {
    let index = 0;
    
    return {
      // Referenz auf das äußere Objekt
      context: this,
      
      // next-Methode des Iterators
      next() {
        if (index < this.context.data.length) {
          return { value: this.context.data[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

// Verwendung mit for...of
for (const item of customIterable) {
  console.log(item); // 10, 20, 30
}

// Spread-Operator mit Iterables
const numbers = [...customIterable]; // [10, 20, 30]

// Destrukturierung mit Iterables
const [first, second, third] = customIterable; // first=10, second=20, third=30

24.2 Generatoren

Generatoren sind spezielle Funktionen, die ihre Ausführung unterbrechen und später an derselben Stelle fortsetzen können. Sie bieten eine elegante Möglichkeit, Iteratoren zu erstellen.

24.2.1 Grundlagen der Generator-Funktionen

Generator-Funktionen werden mit einem Sternchen (*) definiert und verwenden das yield-Schlüsselwort, um Werte zurückzugeben und die Ausführung zu pausieren:

function* simpleGenerator() {
  yield 1;
  yield 2;
  yield 3;
}

const generator = simpleGenerator();

console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 2, done: false }
console.log(generator.next()); // { value: 3, done: false }
console.log(generator.next()); // { value: undefined, done: true }

24.2.2 Werte an Generatoren übergeben

Generatoren können nicht nur Werte zurückgeben, sondern auch Werte beim Fortsetzen empfangen:

function* communicatingGenerator() {
  const x = yield 'Bitte gib einen Wert ein:';
  const y = yield `Dein Wert (${x}) wurde verarbeitet. Bitte gib einen weiteren Wert ein:`;
  yield `Deine Werte addiert ergeben: ${x + y}`;
}

const generator = communicatingGenerator();

console.log(generator.next().value);
// 'Bitte gib einen Wert ein:'

console.log(generator.next(10).value);
// 'Dein Wert (10) wurde verarbeitet. Bitte gib einen weiteren Wert ein:'

console.log(generator.next(20).value);
// 'Deine Werte addiert ergeben: 30'

24.2.3 Generatoren als Iteratoren

Jeder Generator implementiert automatisch sowohl das Iterable- als auch das Iterator-Protocol:

function* countUpTo(max) {
  let count = 1;
  while (count <= max) {
    yield count++;
  }
}

// Verwendung mit for...of
for (const num of countUpTo(5)) {
  console.log(num); // 1, 2, 3, 4, 5
}

// Spread-Operator
const numbers = [...countUpTo(5)]; // [1, 2, 3, 4, 5]

// Destrukturierung
const [first, second, ...rest] = countUpTo(5); // first=1, second=2, rest=[3, 4, 5]

24.2.4 Delegieren von Generatoren

Mit dem yield*-Ausdruck können wir die Kontrolle an einen anderen Generator oder ein Iterable delegieren:

function* gen1() {
  yield 1;
  yield 2;
}

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

function* combined() {
  yield* gen1();
  yield* gen2();
  yield 'Ende';
}

for (const value of combined()) {
  console.log(value); // 1, 2, 'a', 'b', 'Ende'
}

24.3 Praktische Anwendungen

24.3.1 Unendliche Sequenzen

Generatoren eignen sich hervorragend für die Erstellung potenziell unendlicher Sequenzen:

function* fibonacci() {
  let [prev, curr] = [0, 1];
  while (true) {
    yield curr;
    [prev, curr] = [curr, prev + curr];
  }
}

const fib = fibonacci();
for (let i = 0; i < 10; i++) {
  console.log(fib.next().value); // 1, 1, 2, 3, 5, 8, 13, 21, 34, 55
}

24.3.2 Durchlaufen von Baumstrukturen

Generatoren vereinfachen das Traversieren komplexer Datenstrukturen wie Bäume:

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

function* inOrderTraversal(node) {
  if (node) {
    yield* inOrderTraversal(node.left);
    yield node.value;
    yield* inOrderTraversal(node.right);
  }
}

// Erstellen eines Beispielbaums
const root = new TreeNode(1);
root.left = new TreeNode(2);
root.right = new TreeNode(3);
root.left.left = new TreeNode(4);
root.left.right = new TreeNode(5);

// Durchlaufen des Baums
for (const value of inOrderTraversal(root)) {
  console.log(value); // 4, 2, 5, 1, 3
}

24.3.3 Paging und Lazy Loading

Generatoren eignen sich gut für Paging-Szenarien, bei denen Daten schrittweise geladen werden:

async function* fetchPaginatedData(url, pageSize = 10) {
  let page = 1;
  let hasMoreData = true;
  
  while (hasMoreData) {
    const response = await fetch(`${url}?page=${page}&pageSize=${pageSize}`);
    const data = await response.json();
    
    if (data.items.length === 0) {
      hasMoreData = false;
    } else {
      yield* data.items;
      page++;
    }
  }
}

// Verwendung
async function loadData() {
  const dataGenerator = fetchPaginatedData('/api/items', 20);
  
  // Laden der ersten 100 Elemente
  let count = 0;
  for await (const item of dataGenerator) {
    processItem(item);
    count++;
    
    if (count >= 100) break;
  }
}

24.4 Asynchrone Generatoren und Iteratoren

Mit ECMAScript 2018 wurden asynchrone Generatoren und Iteratoren eingeführt, die besonders für asynchrone Datenströme nützlich sind.

24.4.1 Asynchrone Iteratoren

Ein asynchroner Iterator ist ähnlich wie ein regulärer Iterator, aber seine next()-Methode gibt ein Promise zurück, das zu einem Objekt mit value und done aufgelöst wird:

const asyncIterable = {
  async *[Symbol.asyncIterator]() {
    for (let i = 1; i <= 5; i++) {
      // Simuliere asynchrone Arbeit
      await new Promise(resolve => setTimeout(resolve, 1000));
      yield i;
    }
  }
};

(async () => {
  for await (const num of asyncIterable) {
    console.log(num); // Ausgabe 1-5 im Abstand von je 1 Sekunde
  }
})();

24.4.2 Asynchrone Generatoren

Asynchrone Generator-Funktionen werden mit async function* definiert und können sowohl await als auch yield verwenden:

async function* fetchCommentsForPosts(posts) {
  for (const post of posts) {
    const response = await fetch(`/api/posts/${post.id}/comments`);
    const comments = await response.json();
    yield { post, comments };
  }
}

async function displayPostComments(posts) {
  const commentGenerator = fetchCommentsForPosts(posts);
  
  for await (const { post, comments } of commentGenerator) {
    console.log(`Post: ${post.title}`);
    console.log(`Kommentare: ${comments.length}`);
  }
}

24.5 Fortgeschrittene Muster mit Generatoren

24.5.1 Zustandsmaschinen mit Generatoren

Generatoren eignen sich hervorragend für die Implementierung von Zustandsmaschinen:

function* trafficLightStateMachine() {
  while (true) {
    yield 'rot';
    yield 'grün';
    yield 'gelb';
  }
}

const trafficLight = trafficLightStateMachine();
console.log(trafficLight.next().value); // 'rot'
console.log(trafficLight.next().value); // 'grün'
console.log(trafficLight.next().value); // 'gelb'
console.log(trafficLight.next().value); // 'rot'

24.5.2 Coroutinen mit Generatoren

Generatoren können als Coroutinen verwendet werden, um komplexe Kontrollflüsse zu implementieren:

function* processData() {
  const data = yield 'Daten laden';
  const filteredData = yield `Verarbeite ${data.length} Datensätze`;
  yield `Fertig: ${filteredData} Ergebnisse gefunden`;
}

function runCoroutine(generator, initialValue) {
  const iterator = generator();
  
  function handle(result, value) {
    if (result.done) return result.value;
    
    console.log(result.value);
    return handle(iterator.next(value), performTask(result.value));
  }
  
  return handle(iterator.next(), initialValue);
}

function performTask(instruction) {
  // Simuliere verschiedene Aufgaben basierend auf der Anweisung
  if (instruction === 'Daten laden') {
    return Array(100).fill().map((_, i) => `Item ${i}`);
  }
  if (instruction.includes('Verarbeite')) {
    return 42; // Anzahl der gefilterten Ergebnisse
  }
  return null;
}

runCoroutine(processData);
// Ausgabe:
// 'Daten laden'
// 'Verarbeite 100 Datensätze'
// 'Fertig: 42 Ergebnisse gefunden'

24.5.3 Asynchroner Kontrollfluss mit Generatoren

Vor der Einführung von async/await wurden Generatoren oft verwendet, um asynchronen Code lesbarer zu gestalten:

function runAsync(generatorFunction) {
  const generator = generatorFunction();
  
  function handle(result) {
    if (result.done) return Promise.resolve(result.value);
    
    return Promise.resolve(result.value)
      .then(value => handle(generator.next(value)))
      .catch(error => handle(generator.throw(error)));
  }
  
  return handle(generator.next());
}

// Verwendung
runAsync(function* () {
  try {
    const users = yield fetch('/api/users').then(r => r.json());
    const firstUser = users[0];
    const posts = yield fetch(`/api/users/${firstUser.id}/posts`).then(r => r.json());
    console.log(posts);
  } catch (error) {
    console.error('Fehler:', error);
  }
});

// Dies ist ähnlich zu folgendem async/await Code:
async function fetchData() {
  try {
    const users = await fetch('/api/users').then(r => r.json());
    const firstUser = users[0];
    const posts = await fetch(`/api/users/${firstUser.id}/posts`).then(r => r.json());
    console.log(posts);
  } catch (error) {
    console.error('Fehler:', error);
  }
}

24.6 Optimierung und Leistungsüberlegungen

24.6.1 Lazy Evaluation

Einer der größten Vorteile von Generatoren ist die Lazy Evaluation - Werte werden nur bei Bedarf berechnet:

function* rangeGenerator(start, end) {
  for (let i = start; i <= end; i++) {
    console.log(`Generiere Zahl ${i}`);
    yield i;
  }
}

const range = rangeGenerator(1, 1000000); // Kein Code wird ausgeführt

// Erst bei Anforderung wird der erste Wert berechnet
console.log(range.next().value); // Logs: "Generiere Zahl 1" und "1"
console.log(range.next().value); // Logs: "Generiere Zahl 2" und "2"

// Der Rest der Sequenz wird niemals berechnet, wenn nicht angefordert

24.6.2 Speichermanagement

Durch Lazy Evaluation können Generatoren mit potenziell riesigen Datenmengen arbeiten, ohne viel Speicher zu verbrauchen:

// Verarbeitung einer großen Datei Zeile für Zeile mit Generatoren
async function* readFileByLine(filePath) {
  const fileHandle = await fs.promises.open(filePath, 'r');
  const reader = fileHandle.createReadStream();
  
  let buffer = '';
  
  try {
    for await (const chunk of reader) {
      buffer += chunk.toString();
      let lineEnd;
      
      while ((lineEnd = buffer.indexOf('\n')) !== -1) {
        yield buffer.slice(0, lineEnd);
        buffer = buffer.slice(lineEnd + 1);
      }
    }
    
    if (buffer.length > 0) {
      yield buffer;
    }
  } finally {
    await fileHandle.close();
  }
}

// Verwendung
async function processHugeFile(filePath) {
  for await (const line of readFileByLine(filePath)) {
    processLine(line);
  }
}

24.7 Iterators und Generators in der Praxis

24.7.1 Eigene Array-Methoden mit Generatoren

Wir können eigene Array-ähnliche Methoden mit Generatoren implementieren:

function* map(iterable, mapFn) {
  for (const item of iterable) {
    yield mapFn(item);
  }
}

function* filter(iterable, filterFn) {
  for (const item of iterable) {
    if (filterFn(item)) {
      yield item;
    }
  }
}

function* take(iterable, limit) {
  let count = 0;
  for (const item of iterable) {
    if (count >= limit) break;
    yield item;
    count++;
  }
}

// Funktionale Pipeline mit Generatoren
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const result = [...take(
  filter(
    map(numbers, x => x * 2), 
    x => x > 5
  ), 
  3
)];

console.log(result); // [6, 8, 10]

24.7.2 Generator-basierte Datastreams

Generatoren können genutzt werden, um Datastreams zu implementieren:

class DataStream {
  constructor(generator) {
    this.generator = generator;
  }
  
  map(fn) {
    const self = this;
    return new DataStream(function* () {
      for (const item of self.generator()) {
        yield fn(item);
      }
    });
  }
  
  filter(fn) {
    const self = this;
    return new DataStream(function* () {
      for (const item of self.generator()) {
        if (fn(item)) {
          yield item;
        }
      }
    });
  }
  
  take(limit) {
    const self = this;
    return new DataStream(function* () {
      let count = 0;
      for (const item of self.generator()) {
        if (count >= limit) break;
        yield item;
        count++;
      }
    });
  }
  
  // Materialisiert den Stream zu einem Array
  toArray() {
    return [...this.generator()];
  }
}

// Verwendung
const stream = new DataStream(function* () {
  for (let i = 1; i <= 100; i++) {
    yield i;
  }
});

const result = stream
  .filter(x => x % 2 === 0)  // Nur gerade Zahlen
  .map(x => x * x)           // Quadrieren
  .take(5)                   // Nur die ersten 5
  .toArray();                // Materialisieren

console.log(result); // [4, 16, 36, 64, 100]