Filter anzeigen

All

News

Referenzen

  • #Software
  • #Development
  • #Testing
  • #JavaScript
  • #TDD
  • #Meetup
  • #Netzwerk
  • #JS Meetup
  • #Technologie
  • #ePrivacy
  • #DSVGO
  • #Datenschutz
  • #Digitalisierung
  • #Agil
  • #Apps
  • #Frontend
  • #Web
  • #Backend
  • #Informationsmanagement
  • #Referenzen
  • #Controlling
  • #Excel
  • #Microsoft
  • #Planning
  • #Projektmanagement
  • #Reporting
  • #SharePoint
  • #Prozesse
  • #Individualsoftware
  • #Förderung
  • #SAB
  • #UseCase
  • #Quality
  • #Electron
  • #React
  • #Tiefbau

Iterables, Iterators und Generators in JavaScript

August 2019

von Andreas Roth

#Software #Development #JavaScript
Jeder JavaScript-Entwickler arbeitet mit Arrays. Doch manchmal ist diese Datenstruktur nicht die richtige Wahl. Wenn man mit extrem großen Datenmengen oder unendlichen Sequenzen arbeiten möchte, kommen Arrays nicht in Frage und man benötigt Alternativen. Eine recht neue, bisher wenig verwendete Option sind Iterables und Iteratoren, die genau für solche Anwendungsfälle entwickelt wurden und in anderen Sprachen (z.B. Python oder C#) schon längst Standard sind.

Iterables statt Arrays in JavaScript

Vorwort

Wir bei esveo wenden in vielen Projekten die Programmiersprache JavaScript bzw. TypeScript an. Aufgrund der vielfältigen Anwendungsmöglichkeiten und der extremen Stärke des Ökosystems dieser Sprachen, war das bisher nie eine schlechte Entscheidung. Im Vergleich zur Entwicklung in anderen Sprachen und Umgebungen ist die Komplexität der Anwendungen erst in den letzten Jahre so angestiegen (wie in einem anderen Artikel erläutert wurde), dass man die Unterstützung von Frameworks und Design Patterns sucht. Eines dieser Patterns ist der Iterator, bzw. Iterables: Ein vereinheitlichtes Muster, mit denen ein Strom an Datenblöcken abgebildet werden kann.

Wieso keine Arrays?

Muss man eine Vielzahl von Werten in einer Variable ablegen, greift man üblicherweise zu einem Array bzw. zu einer Liste. Wieso sollte man jetzt also weg vom bewährten Vorgehen? Der große Nachteil bei Arrays ist, dass alle Werte gleichzeitig im Speicher gehalten werden müssen. Muss bei der serverseitigen Programmierung zum Beispiel eine Datei eingelesen und verarbeitet werden, sollte man die Zeilen der Datei nicht gleichzeitig laden und anschließend verarbeiten, da der Arbeitsspeicher des Servers so nur unnötig befüllt wird. Stattdessen verarbeitet man jede Zeile einzeln und kann so eine hohe Spitze im Ressourcenverbrauch vermeiden. Genau an diesem Punkt kommen Iterables und Iterator ins Spiel.

Iterables & Iterator

Der grundlegende Baustein für die Verwendung von Iterables ist der Iterator. Ein Object, auf welchem man die next() Methode aufrufen kann, um den nächsten Wert zu erhalten. Als Resultat erhält man hierbei ein Objekt mit den Schlüsseln value und done, die den aktuellen Wert, bzw. die Information über den Zustand des Iterators beinhalten.

In TypeScript sieht das Interface des Iterators und des IteratorResults ungefähr (für die Erklärung wurden 2 Methoden wegglassen, die hier nicht benötigt werden) so aus:

interface IteratorResult<ValueType> {
  done: boolean;
  value: ValueType;
}

interface Iterator<ValueType> {
  next: (value?: any) => IteratorResult<ValueType>;
}

Eine Beispielimplementierung, die eine Reihe aufsteigender Zahlen erzeugt, könnte so aussehen:

let n = 0;
const numberIterator = {
  next: () => {
    const nextResult = { value: n, done: false };
    n++;
    return nextResult;
  },
};

numberIterator.next(); // { value: 0, done: false }
numberIterator.next(); // { value: 1, done: false }
numberIterator.next(); // { value: 2, done: false }

Für eine einfache Zahlenfolge ist das doch erstaunlich viel Aufwand. Noch dazu kommt, dass ein einzelner zustandsbehafteter Iterator wenig Mehrwert bietet. Stattdessen möchte man eher seine eigenen Klassen als "iterierbar" markieren. Dafür wird das Interface des Iterables verwendet. Per Definition ist ein Objekt ein Iterable, wenn es im Schlüssel Symbol.iterator eine Methode enthält, die einen Iterator als Resultat liefert:

interface Iterable<ValueType> {
  [Symbol.iterator](): Iterator<ValueType>;
}

Eine Implementierung der Zahlenfolge könnte dann so aussehen:

const numbers = {
  [Symbol.iterator]: () => {
    let n = 0;
    return {
      next: () => {
        const nextResult = { value: n, done: false };
        n++;
        return nextResult;
      },
    };
  },
};

So ist der Schreibaufwand sogar noch größer, um etwas simples wie eine Zahlenfolge zu implementieren. Eine Besonderheit dieser Lösungen soll hier hervorgehoben werden: Die Zahlenfolge ist unendlich. Das ließe sich in einem Array nicht abbilden, da es immer eine endliche Länge haben muss, da alle Elemente des Arrays gleichzeitig im Speicher gehalten werden müssen. Da das Iterable eine einheitlich definierte Schnittstelle ist, können Sprachfeatures darauf aufgebaut werden:

const iterableWithNumbersFromOneToThree = {}; // >> Implementierung des iterable ausgelassen
for (const number of iterableWithNumbersFromOneToThree) {
  console.log(number); // 1, 2, 3
}

const arrayFromIterable = [...iterableWithNumbersFromOneToThree]; // [1, 2, 3]

console.log(...iterableWithNumbersFromOneToThree); // Gleich wie console.log(1, 2, 3)

Da die Verwendung von Iterables verbreitet werden soll, wurde zudem eine neue Art Funktion zu JavaScript hinzugefügt: Der Generator. Ein Generator ist eine Funktion, die als Rückgabewert einen Iterable Iterator liefert. Also einen Iterator (mit next-Methode) der gleichzeitig ein Iterable ist (mit Symbol.iterator Methode). Die Schreibweise sieht wie folgt aus:

function* numberGenerator() {
  let n = 0;
  while (true) {
    yield n;
    n++;
  }
}

Hier muss auf zwei Besonderheiten geachtet werden: Der * vor dem Funktionsnamen zeigt an, dass es sich bei dieser Funktion um einen Generator handelt. Das yield n im Funktionsblock, erzeugt den nächsten Wert im Iterable. Nach der yield Anweisung pausiert der Funktionscode in der Funktion und läuft erst weiter, wenn von außen die .next() Methode aufgerufen wird. Aus diesem Grund friert das while (true) den Browser auch nicht ein.

Iterable Hilfsfunktionen

Jeder der in JavaScript schon öfter mit Listen von Daten gearbeitet hat, wird Hilfsfunktionen wie .map oder .filter kennen. Diese müssen für die Iterables einmalig selbst implementiert werden (wenn man keine externe Bibliothek hinzuziehen möchte). Doch hier braucht man sich keine Sorgen machen, die Implementierung ist sehr einfach:

// Transformiere jeden Wert im iterable mit einer gegebenen Funktion:
function* map(iterable, transform) {
  for (let item of iterable) {
    yield transform(item);
  }
}

// Prüfe für jeden Wert im Iterable den Rückgabewert einer Funktion.
// Nur bei `true` wird der Wert behalten.
function* filter(iterable, condition) {
  for (let item of iterable) {
    if (condition(item)) yield item;
  }
}

Als kleines Beispiel, soll jetzt noch eine simple Transformation umgesetzt werden:

function transform(numbers: Iterable<number>) {
  const squared = map(numbers, n => n * n);
  const even = map(numbers, n => n % 2 === 0);

  return even;
}

Diese Funktion quadriert alle Zahlen in einem Iterable, quadriert sie und entfernt alle ungeraden. Hier muss wieder betont werden: Iterables sind "lazy"! Das heißt, es werden nur die Werte berechnet, die wirklich benötigt werden:

const infinite = numberGenerator();
const transformed = transform(infinite);

// Bis hier hin wurde noch keine Berechnung durchgeführt.

let i = 0;
for (const n of transformed) {
  if (i > 10) break;
  i++;
  console.log(n);
}
// Hier werden die ersten 10 Ergebnisse des Iterators
// "gezogen" und auf die Konsole geschrieben.

Sowohl die Filter, als auch die Transformationen werden nur so oft durchgeführt, wie für die ersten 10 Ergebnisse benötigt wird.

Wofür das alles?

Der größte Vorteil in der Verwendung des Iterable Interfaces versteckt sich in den SOLID-Prinzipien, genauer gesagt im Dependency Inversion Prinzip. Dieses besagt, dass Code immer nur von Interfaces und nicht von konkreten Implementierungen abhängen sollte. Würde die transform Funktion direkt mit den Array-Methoden arbeiten, könnte die Funktion auch nur mit Arrays aufgerufen werden und wir wären in der Verwendung unnötig eingeschränkt. Hier kommt jetzt der größte Vorteil in der Verwendung von Iterables: Die eingebauten Sprach- und Browserfunktionen verwenden bereits Iterables: Arrays, Sets, Maps, NodeLists und viele weitere implementieren bereits das Iterable Interface. Das bedeutet, dass unsere transform-Funktion auch mit diesen Datenstrukturen aufgerufen werden kann und trotzdem funktioniert. So bleiben die Funktionen flexibel und können für viele Anwendungsfälle benutzt werden. Gerade in der Backend-Entwicklung ist es einfach manchmal nötig, eigene Datenstrukturen, wie eine Queue oder eine LinkedList zu implementieren. Wenn diese auch Iterables sind, können viele Hilfsfunktionen trotzdem weiterverwendet werden.

Iterables, Iterators und Generators in JavaScript

August 2019

von Andreas Roth

#Software #Development #JavaScript

Jeder JavaScript-Entwickler arbeitet mit Arrays. Doch manchmal ist diese Datenstruktur nicht die richtige Wahl. Wenn man mit extrem großen Datenmengen oder unendlichen Sequenzen arbeiten möchte, kommen Arrays nicht in Frage und man benötigt Alternativen. Eine recht neue, bisher wenig verwendete Option sind Iterables und Iteratoren, die genau für solche Anwendungsfälle entwickelt wurden und in anderen Sprachen (z.B. Python oder C#) schon längst Standard sind.

Iterables statt Arrays in JavaScript

Vorwort

Wir bei esveo wenden in vielen Projekten die Programmiersprache JavaScript bzw. TypeScript an. Aufgrund der vielfältigen Anwendungsmöglichkeiten und der extremen Stärke des Ökosystems dieser Sprachen, war das bisher nie eine schlechte Entscheidung. Im Vergleich zur Entwicklung in anderen Sprachen und Umgebungen ist die Komplexität der Anwendungen erst in den letzten Jahre so angestiegen (wie in einem anderen Artikel erläutert wurde), dass man die Unterstützung von Frameworks und Design Patterns sucht. Eines dieser Patterns ist der Iterator, bzw. Iterables: Ein vereinheitlichtes Muster, mit denen ein Strom an Datenblöcken abgebildet werden kann.

Wieso keine Arrays?

Muss man eine Vielzahl von Werten in einer Variable ablegen, greift man üblicherweise zu einem Array bzw. zu einer Liste. Wieso sollte man jetzt also weg vom bewährten Vorgehen? Der große Nachteil bei Arrays ist, dass alle Werte gleichzeitig im Speicher gehalten werden müssen. Muss bei der serverseitigen Programmierung zum Beispiel eine Datei eingelesen und verarbeitet werden, sollte man die Zeilen der Datei nicht gleichzeitig laden und anschließend verarbeiten, da der Arbeitsspeicher des Servers so nur unnötig befüllt wird. Stattdessen verarbeitet man jede Zeile einzeln und kann so eine hohe Spitze im Ressourcenverbrauch vermeiden. Genau an diesem Punkt kommen Iterables und Iterator ins Spiel.

Iterables & Iterator

Der grundlegende Baustein für die Verwendung von Iterables ist der Iterator. Ein Object, auf welchem man die next() Methode aufrufen kann, um den nächsten Wert zu erhalten. Als Resultat erhält man hierbei ein Objekt mit den Schlüsseln value und done, die den aktuellen Wert, bzw. die Information über den Zustand des Iterators beinhalten.

In TypeScript sieht das Interface des Iterators und des IteratorResults ungefähr (für die Erklärung wurden 2 Methoden wegglassen, die hier nicht benötigt werden) so aus:

interface IteratorResult<ValueType> {
  done: boolean;
  value: ValueType;
}

interface Iterator<ValueType> {
  next: (value?: any) => IteratorResult<ValueType>;
}

Eine Beispielimplementierung, die eine Reihe aufsteigender Zahlen erzeugt, könnte so aussehen:

let n = 0;
const numberIterator = {
  next: () => {
    const nextResult = { value: n, done: false };
    n++;
    return nextResult;
  },
};

numberIterator.next(); // { value: 0, done: false }
numberIterator.next(); // { value: 1, done: false }
numberIterator.next(); // { value: 2, done: false }

Für eine einfache Zahlenfolge ist das doch erstaunlich viel Aufwand. Noch dazu kommt, dass ein einzelner zustandsbehafteter Iterator wenig Mehrwert bietet. Stattdessen möchte man eher seine eigenen Klassen als "iterierbar" markieren. Dafür wird das Interface des Iterables verwendet. Per Definition ist ein Objekt ein Iterable, wenn es im Schlüssel Symbol.iterator eine Methode enthält, die einen Iterator als Resultat liefert:

interface Iterable<ValueType> {
  [Symbol.iterator](): Iterator<ValueType>;
}

Eine Implementierung der Zahlenfolge könnte dann so aussehen:

const numbers = {
  [Symbol.iterator]: () => {
    let n = 0;
    return {
      next: () => {
        const nextResult = { value: n, done: false };
        n++;
        return nextResult;
      },
    };
  },
};

So ist der Schreibaufwand sogar noch größer, um etwas simples wie eine Zahlenfolge zu implementieren. Eine Besonderheit dieser Lösungen soll hier hervorgehoben werden: Die Zahlenfolge ist unendlich. Das ließe sich in einem Array nicht abbilden, da es immer eine endliche Länge haben muss, da alle Elemente des Arrays gleichzeitig im Speicher gehalten werden müssen. Da das Iterable eine einheitlich definierte Schnittstelle ist, können Sprachfeatures darauf aufgebaut werden:

const iterableWithNumbersFromOneToThree = {}; // >> Implementierung des iterable ausgelassen
for (const number of iterableWithNumbersFromOneToThree) {
  console.log(number); // 1, 2, 3
}

const arrayFromIterable = [...iterableWithNumbersFromOneToThree]; // [1, 2, 3]

console.log(...iterableWithNumbersFromOneToThree); // Gleich wie console.log(1, 2, 3)

Da die Verwendung von Iterables verbreitet werden soll, wurde zudem eine neue Art Funktion zu JavaScript hinzugefügt: Der Generator. Ein Generator ist eine Funktion, die als Rückgabewert einen Iterable Iterator liefert. Also einen Iterator (mit next-Methode) der gleichzeitig ein Iterable ist (mit Symbol.iterator Methode). Die Schreibweise sieht wie folgt aus:

function* numberGenerator() {
  let n = 0;
  while (true) {
    yield n;
    n++;
  }
}

Hier muss auf zwei Besonderheiten geachtet werden: Der * vor dem Funktionsnamen zeigt an, dass es sich bei dieser Funktion um einen Generator handelt. Das yield n im Funktionsblock, erzeugt den nächsten Wert im Iterable. Nach der yield Anweisung pausiert der Funktionscode in der Funktion und läuft erst weiter, wenn von außen die .next() Methode aufgerufen wird. Aus diesem Grund friert das while (true) den Browser auch nicht ein.

Iterable Hilfsfunktionen

Jeder der in JavaScript schon öfter mit Listen von Daten gearbeitet hat, wird Hilfsfunktionen wie .map oder .filter kennen. Diese müssen für die Iterables einmalig selbst implementiert werden (wenn man keine externe Bibliothek hinzuziehen möchte). Doch hier braucht man sich keine Sorgen machen, die Implementierung ist sehr einfach:

// Transformiere jeden Wert im iterable mit einer gegebenen Funktion:
function* map(iterable, transform) {
  for (let item of iterable) {
    yield transform(item);
  }
}

// Prüfe für jeden Wert im Iterable den Rückgabewert einer Funktion.
// Nur bei `true` wird der Wert behalten.
function* filter(iterable, condition) {
  for (let item of iterable) {
    if (condition(item)) yield item;
  }
}

Als kleines Beispiel, soll jetzt noch eine simple Transformation umgesetzt werden:

function transform(numbers: Iterable<number>) {
  const squared = map(numbers, n => n * n);
  const even = map(numbers, n => n % 2 === 0);

  return even;
}

Diese Funktion quadriert alle Zahlen in einem Iterable, quadriert sie und entfernt alle ungeraden. Hier muss wieder betont werden: Iterables sind "lazy"! Das heißt, es werden nur die Werte berechnet, die wirklich benötigt werden:

const infinite = numberGenerator();
const transformed = transform(infinite);

// Bis hier hin wurde noch keine Berechnung durchgeführt.

let i = 0;
for (const n of transformed) {
  if (i > 10) break;
  i++;
  console.log(n);
}
// Hier werden die ersten 10 Ergebnisse des Iterators
// "gezogen" und auf die Konsole geschrieben.

Sowohl die Filter, als auch die Transformationen werden nur so oft durchgeführt, wie für die ersten 10 Ergebnisse benötigt wird.

Wofür das alles?

Der größte Vorteil in der Verwendung des Iterable Interfaces versteckt sich in den SOLID-Prinzipien, genauer gesagt im Dependency Inversion Prinzip. Dieses besagt, dass Code immer nur von Interfaces und nicht von konkreten Implementierungen abhängen sollte. Würde die transform Funktion direkt mit den Array-Methoden arbeiten, könnte die Funktion auch nur mit Arrays aufgerufen werden und wir wären in der Verwendung unnötig eingeschränkt. Hier kommt jetzt der größte Vorteil in der Verwendung von Iterables: Die eingebauten Sprach- und Browserfunktionen verwenden bereits Iterables: Arrays, Sets, Maps, NodeLists und viele weitere implementieren bereits das Iterable Interface. Das bedeutet, dass unsere transform-Funktion auch mit diesen Datenstrukturen aufgerufen werden kann und trotzdem funktioniert. So bleiben die Funktionen flexibel und können für viele Anwendungsfälle benutzt werden. Gerade in der Backend-Entwicklung ist es einfach manchmal nötig, eigene Datenstrukturen, wie eine Queue oder eine LinkedList zu implementieren. Wenn diese auch Iterables sind, können viele Hilfsfunktionen trotzdem weiterverwendet werden.

Bevorzugte Kontaktaufnahme

esveo wird alle hier bereitgestellten Informationen ausschließlich in Übereinstimmung mit der Datenschutzerklärung verwenden