JavaScript/Iterator
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.
Inhaltsverzeichnis
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, wenndone
den Wertfalse
hat.
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:
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);
}
[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.
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
durchfunction*
ersetzen - In der Funktion das Schlüsselwort
yield
verwenden, um den Wert für dasIteratorResult
-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.
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.
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.
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:
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:
yield* kindIterator;
Schauen wir uns an, wie das in einem rekursiven Kommentarfinder für das DOM aussehen könnte:
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:
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)
Siehe auch
Weblinks
- MDN: Iteratoren und Generatoren (englisch)