JavaScript/Iterator

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

Ein Iterator-Objekt ist ein Baustein des iterator/iterable Protokolls, das in ECMAScript 2015 eingeführt wurde.

Iterierbare Objekte enthalten eine Menge von Werten. Um diese Menge Element für Element verarbeiten zu können, stellen sie ein Iterator-Objekt zur Verfügung.

Einführung

Grundsätzlich sind iterierbare Objekte nichts Neues, es gibt sie schon lange. Arrays sind iterierbar, die Eigenschaften eines Objektes ebenfalls. Das Problem war nur, dass es keine einheitliche Schnittstelle dafür gab. Das in ECMAScript 2015 eingeführte iterable-Protokoll ist eine Voraussetzung für die for..of Schleife und den Spread-Operator.

Damit ein Objekt iterierbar ist (oder: das iterable-Protokoll befolgt), muss es die @@iterator Methode bereitstellen. @@iterator ist ein Symbol, das vom Symbol Objekt unter der Eigenschaft Symbol.iterator bereitgestellt wird. Das Objekt kann die Methode selbst implementieren, oder über seine Prototypenkette erben.

Wer iterieren möchte, ruft diese Methode auf und benutzt das erhaltene Iterator-Objekt. Solche Objekte befolgen das iterator Protokoll. Sie definieren dafür eine Methode next(), die pro Iterationsschritt einmal aufzurufen ist und ein Objekt mit zwei Eigenschaften zurückgibt:

done
Ein boolescher Wert, der angibt, ob das Ende der Aufzählung erreicht ist. true gibt an, dass die Iteration zu Ende ist und kein weiteres Element mehr zur Verfügung steht.
value
Einer der im iterierbaren Objekt gespeicherten Werte. Das Vorhandensein von value ist nur garantiert, wenn done den Wert false hat.
Beachten Sie: Ein iterierbares Objekt stellt keine einheitliche Methode zur Verfügung, um die Anzahl der im Objekt gespeicherten Elemente zu ermitteln. Das iterable-Protokoll befasst sich ausschließlich mit dem sequenziellen Durchlaufen dieser Elemente.
Verwendung eines iterierbaren Objekts
let liste = [ "Deutschland", "Frankreich", "Belgien", "Niederlande" ];

for (land of liste) {
   console.log(land);
}

Wie jetzt – wo ist der Iterator? Dieses Beispiel zeigt ein iterierbares Objekt (ein Array), das mit Hilfe der for..of Schleife durchlaufen wird. Dazu verwendet for..of den vom Array bereitgestellten Iterator.

Einen vorhandenen Iterator benutzen

Bauen wir das, was for..of tut, einmal nach:

Ein iterierbares Objekt selbst iterieren
let liste = [ "Deutschland", "Frankreich", "Belgien", "Niederlande" ];
let iterator = liste[Symbol.iterator]();
while (true) {
   let entry = iterator.next();
   if (entry.done) break;
   console.log(entry.value);
}
Beachten Sie: [Symbol.iterator] ist eine Methode. Die Klammern dahinter sind unbedingt erforderlich.

Die Verwendung von for..of ist deutlich bequemer. Es kann aber Fälle geben, wo spezielleres Verhalten beim Durchlaufen eines iterable-Objekts gewünscht ist, und man die Iteration von Hand durchführen möchte. Beispielsweise, wenn man mehrere iterierbare Objekte parallel durchlaufen will.

Einen Iterator selbst erstellen

Schauen wir nun unter die Motorhaube und bauen einen eigenen Iterator für das Länder-Array. Er unterscheidet sich vom Standard-Iterator eines Arrays in zwei Aspekten. Zum einen überspringt er leere Einträge im Array, und zum anderen liefert er Index und Wert des Array-Elements.

Hinweis:
Es ist sehr umständlich, einen Iterator auf diese Weise zu erstellen und wird eigentlich nur der Vollständigkeit halber gezeigt. Verwenden Sie besser einen Generator, wie im nachfolgenden Abschnitt beschrieben.
Ein eigener Iterator
let liste = [ "Belgien", "Deutschland", "Frankreich", "Niederlande" ];
liste[9] = "Österreich";
liste[Symbol.iterator] = function() {
   let index = -1;
   let arr = this;
   return {
      next() {
         while (true) {
            index++;
            if (index >= arr.length) return { done: true };
            if (arr[index] !== undefined) break;
         }
         return {
            done: false,
            value: { index: index, entry: arr[index] }
         };
      }
   }
}

for (land of liste) {
   console.log(land);
}

Wenn man dieses Programm laufen lässt, findet man als Ergebnis eine Liste von Objekten mit Index und Wert der gefüllten Array-Einträge.

   {index: 0, entry: "Deutschland"}
   {index: 1, entry: "Frankreich"}
   {index: 2, entry: "Belgien"}
   {index: 3, entry: "Niederlande"}
   {index: 9, entry: "Österreich"}

Wo JavaScript Iteratoren erwartet

Wir haben mit for..of bereits eine Stelle gesehen, wo ein iterierbares Objekt erwartet wird. Eine andere Stelle ist der Spread-Operator ..., der ein iterierbares Objekt in seine einzelnen Werte zerlegt. Ebenso unterstützten viele Methoden iterables als Datenquelle, z. B. Array.from oder Promise.all.

Generatoren

Es ist relativ mühsam, einen Iterator von Hand zu schreiben. Das liegt vor allem daran, dass man dafür eine invertierte Programmlogik benutzen muss, d. h. an der Stelle, wo der nächste Wert der Iteration verfügbar ist, muss man sich den aktuellen Zustand merken, die next() Funktion verlassen und beim nächsten Aufruf an der gemerkten Position fortfahren. Diese Aufgabe wird durch einen Generator deutlich vereinfacht.

Ein Generator ist ein besonderer Typ von Funktion. Diese Funktion enthält das Schlüsselwort yield, das anzeigt, dass hier ein Iterationswert zur Verfügung gestellt wird. JavaScript baut den Programmcode der Generatorfunktion automatisch in den Konstruktor für ein Iterator-Objekt mit einer next()-Methode um.

Um einen Generator zu erzeugen, müssen Sie zwei Dinge tun:

  • Das Schlüsselwort function durch function* ersetzen
  • In der Funktion das Schlüsselwort yield verwenden, um den Wert für das IteratorResult-Objekt bereitzustellen.

Das folgende Beispiel zeigt einen Generator, der einem Array als Iterator zugewiesen wird und diejenigen Arraywerte liefert, die nicht undefined sind. Damit man weiß, von welcher Stelle im Array diese Werte stammen, stellt yield nicht nur den Arraywert bereit, sondern ein Objekt aus Index und Wert.

Verwendung der Generatorsyntax für einen Iterator
let liste = [ "Belgien", "Deutschland", "Frankreich", "Niederlande" ];
liste[9] = "Österreich";

// --------------------------------------- Der Generator

liste[Symbol.iterator] = function* () {         // Beachten Sie: function*
   for (let i=0; i<this.length; i++) {
      if (this[i] !== undefined)
         yield { index: i, wert: this[i] }     // Wert mit yield bereitstellen
   }
}
// ----------------------------------------

for (let eintrag of liste) {                       // Liste mit for...of iterieren
   console.log(`liste[${eintrag.index}] = ${eintrag.wert}`);
}

let iterator = liste[Symbol.iterator]();           // Alternativ: manuell iterieren
while (true) {
   let entry = iterator.next();
   if (entry.done) break;
   console.log(entry.value);
}

Das Beispiel zeigt, dass man einen per Generator erstellten Iterator bequem mit for...of durchlaufen, aber auch genauso gut das Basiswerkzeug next() nutzen kann. Aus Sicht des Verwenders ist das Ergebnis eines Generatoraufrufs ein ganz normaler Iterator.

Generatoren ermöglichen es Ihnen, einen Iterator zu erstellen, ohne Ihre Logik dafür auf den Kopf stellen zu müssen. Gerade bei aufwändigerer Logik im Generator ist die Erstellung der next-Funktion sehr fehleranfällig.

Um dem Nutzer anzuzeigen, dass die Iteration zu Ende ist, muss der Generator ein IteratorResult-Objekt zu erzeugen, dessen done-Eigenschaft auf true gesetzt ist. Damit das geschieht, müssen Sie die Generatorfunktion mit return verlassen. Das vorherige Beispiel hat das indirekt getan: wenn die Programmausführung über das Ende eines Funktionsblocks hinausläuft, entspricht das einem return undefined;. Der Wert, den Sie mit return zurückgeben, wird in der value-Eigenschaft des IteratorResult-Objekts abgelegt.

Zu beachten ist auch, dass yield kein Statement ist, sondern ein (unärer) Operator. Zum einen nimmt dieser Operator den Wert entgegen, der im IteratorResult-Objekt als value-Eigenschaft zu finden ist. Zum anderen gibt yield aber auch einen Wert zurück! Sie können nämlich, wenn Sie die Iteration mit next() selbst programmieren, an next() einen Wert übergeben. Und dieser Wert wird zum Rückgabewert des yield-Operators. Auf diese Weise können Sie vom Iterator nicht nur eine Auflistung von Werten erhalten, sondern dem Iterator für seinen nächsten Durchlauf auch noch einen Wert mitgeben. Ein mögliches Anwendungsbeispiel dafür wäre eine Generatorfunktion, die das DOM durchläuft. Der Parameter für next() könnte festlegen, ob die Kind-Elemente des zuletzt von yield bereitgestellten Elements ebenfalls durchlaufen werden sollen oder nicht. Ob sich auf diese Weise lesbarer und verständlicher Code ergibt, ist im Einzelfall zu prüfen.

Beachten Sie: Nichts?

Eigenständige Generatoren

Das Array-Beispiel zeigte einen Generator, der einem Array-Objekt als Iterator zugeordnet war. Diese Kopplung ist aber nur eine Möglichkeit. Genauso gut können Sie dem Generator das Objekt, das er iterieren soll, auch als Argument übergeben.

Generator mit Werteübergabe als Argument
function* GetPositiveValues(iterable) {
   for (let wert of iterable) {
      if (wert !== undefined)
         yield wert;
   }
}

const array = [ 1, 3, -6, 0, 9, -17 ];
for (let wert of GetPositiveValues(array)) {
   console.log(wert);
}

Eine weitere Möglichkeit ist, dass der Generator von einer Methode eines Objekts zurückgegeben wird. Das ist dann so ähnlich wie die Methoden map oder filter eines Array-Objekts, aber nicht identisch. Denn map und filter erzeugen neue Arrays, die man speichern und beliebig weiterverarbeiten kann. Einen Iterator kann man dagegen nur einmal, und auch nur vorwärts, durchlaufen. Ist man am Ende, kann man mit dem Iterator nichts mehr anfangen.

Mehrstufige Iteration und Delegation mit yield*

Es gibt den Fall, dass Sie über eine Liste von Objekten laufen möchten, die jeweils eine Liste von Werten bereitstellen, und die Gesamtmenge all dieser Werte benötigen. Sie könnten das in einem Generator so lösen, dass Sie zwei Schleifen ineinander schachteln - die äußere durchläuft die Objekte und die innere durchläuft die Werteliste je Objekt. Je Wert können Sie dann ein yield ausführen.

Für starre Datenstrukturen ist das kein Problem. Aber stellen Sie sich vor, Sie möchten das DOM einer HTML-Seite durchlaufen und alle Kommentare finden. Die Kommentare auf der obersten Ebene können Sie noch mit yield herausgeben. Wenn Sie aber ein Element mit Kindelementen finden, müssen Sie dessen Elemente durchlaufen. Das tut man sinnvollerweise mit einem rekursiven Funktionsaufruf. Das Problem ist: wenn Sie darin einen Kommentar finden, müssten Sie irgendwo tief in der Rekursion absetzen, sich den kompletten Aufrufstapel merken, den Kommentar aus dem next()-Aufruf zurückgeben und beim nächsten Aufruf alles wiederherstellen. Das möchte niemand tun (und ab einer gewissen Komplexität kann es auch niemand mehr tun). Der yield-Operator hilft Ihnen auch nicht weiter, denn yield ist nur in der Generatorfunktion selbst verwendbar, nicht in Funktionen, die davon aufgerufen werden.

Die Lösung besteht darin, dass man für die Kindelemente eine neue Iteratorinstanz erzeugt. Man muss dann nur noch alle Elemente, die dieser Kind-Iterator liefert, an den Aufrufer des Eltern-Iterators zurückgeben. Man könnte das so tun:

Manuelles Durchreichen der Werte eines Kind-Iterators
for (let wert of kindIterator)
   yield wert;

Wenn man die gelieferten Werte noch irgendwie verarbeiten muss, ist dieses Vorgehen unvermeidlich. Aber wenn es nur darum geht, sie durchzureichen, dann ist das so lästig, dass die JavaScript-Sprachentwickler uns dafür eine Kurzform bereitgestellt haben:

Direktes Durchreichen der Werte eines Kind-Iterators
yield* kindIterator;

Schauen wir uns an, wie das in einem rekursiven Kommentarfinder für das DOM aussehen könnte:

Ein rekursiver Generator mit yield*
function* getCommentNodes(node) {
   for (let childNode of node.childNodes) {
      if (childNode.nodeType == Node.COMMENT_NODE) {
         yield childNode;
      }
      else if (childNode.hasChildNodes) {
         yield* getCommentNodes(childNode);
      }
   }
}

for (let commentNode of document) {
  console.log("Comment: " + commentNode.textContent);
}

yield* ist ebenfalls ein Operator, kein Statement. Er liefert den Rückgabewert der aufgerufenen Generatorfunktion (also den Wert der value-Eigenschaft des IteratorResult-Objekts, in dem done auf true gesetzt wurde)

Wenn Sie den Eltern-Iterator mit next()-Aufrufen durchlaufen und dabei an next() ein Argument übergeben, dann delegiert yield* dieses Argument an die (intern durchgeführten) next()-Aufrufe des Kind-Iterators. Sie können sich den Ablauf so wie in dieser Funktion vorstellen:

Veranschaulichung von yield*
function* simulateYieldWithAsterisk(iterable) {
   let nextArg = undefined;
   while(true) {
      let interationResult = iterable.next(nextArg);
      if (iterationResult.done) {
         // für done=true enthält value den Rückgabewert der Generatorfunktion
         return iterationResult.value;
      }
      nextArg = yield initerationStep.value;
   }
}

Generatoren machen ein Konzept verfügbar, das aus anderen Programmiersprachen als Koroutine bekannt ist. Zwei Programmstücke – Routinen – laufen parallel bzw. quasiparallel und spielen sich gegenseitig den Ball zu, wer fortsetzen darf. Viele Sprachen (z. B. Python) setzen das durch Multithreading um, d. h. Generator und Konsument laufen echt parallel. JavaScript tut das nicht, es verwendet eine Simulationstechnik, um Koroutinen in einem einzigen Thread auszuführen.

Async-Generatoren

Seit ECMAScript 2017 können Generatoren auch async-Funktionen sein (allerdings keine Pfeilfunktionen). Das ist dann sinnvoll, wenn innerhalb der Generatorfunktion auf asynchrone Schnittstellen zurückgegriffen werden muss.

Ein async-Generator erzeugt ein iterable, dessen next()-Funktion ein Promise zurückgibt. Dieses wird zu einem normalen Iterationsobjekt mit den done- und value-Eigenschaften aufgelöst.

Eine Anwendung dafür könnte ein Generator sein, der mittels fetch mehrere JSON-Dateien lädt und pro yield-Aufruf den Inhalt einer dieser Dateien bereitstellt.

ToDo (weitere ToDos)

Artikel muss qualitätsgesichert werden.Rolf b (Diskussion) 23:07, 10. Okt. 2020 (CEST)

Siehe auch

Weblinks