Einführung in die asynchrone JavaScript-Programmierung

Seite 3: Promises, yield

Inhaltsverzeichnis

Ein verhältnismäßig weitverbreiteter Ansatz ist der Einsatz von Promises. Dabei handelt es sich um spezielle Objekte, die eine Funktion synchron zurückgeben kann, deren Wert das Programm aber erst zu einem späteren Zeitpunkt festlegt.

ECMAScript 2015 (ehemals ECMAScript 6 "Harmony") enthält den Promise-Konstruktor serienmäßig, weshalb der Einsatz eines Polyfills inzwischen nicht mehr zwingend erforderlich ist. Die große Ausnahme ist leider wieder einmal der Internet Explorer.

Um ein Promise zu erzeugen, muss man den Konstruktor aufrufen und einen Callback übergeben, der seinerseits zwei Funktionen entgegennimmt: resolve und reject. Sie sind einzusetzen, um das Versprechen einzulösen beziehungsweise es im Fehlerfall zu brechen:

return new Promise((resolve, reject) => {
// ...
});

Schreibt man die bisher asynchrone load-Funktion auf den Einsatz eines Promises um, entsteht der folgende Code. Primär unterscheidet er sich von der asynchronen Variante lediglich durch das Fehlen des Callbacks:

let load = function (filename) {
load.cache = load.cache || {};

return Promise((resolve, reject) => {
let data = load.cache[filename];

if (data) {
return process.nextTick(() => {
resolve(data.toString('utf8'));
});
}

fs.readFile(filename, (err, data) => {
if (err) {
return reject(err);
}

load.cache[filename] = data;
resolve(data.toString('utf8'));
});
});
};

Ruft man die Funktion load auf, erhält man ein Promise zurück, das wiederum Funktionen wie then und catch bietet, um die zurückgegebenen Daten beziehungsweise einen aufgetretenen Fehler zu verarbeiten:

load('/etc/passwd').then(data => {
// ...
}).catch(err => {
// ...
});

Da asynchrone Funktionen in Node.js stets dem Schema folgen, Parametern zunächst einen Fehler und erst danach die eigentlichen Daten zu übergeben, lässt sich leicht eine Funktion promisify schreiben, die eine beliebige mit Callbacks arbeitende Funktion in eine überführt, die Promises nutzt:

let promisify = function (obj, fn) {
return function (...args) {
return new Promise((resolve, reject) => {
obj[fn].apply(obj, [...args, (err, ...result) => {
if (err) {
return reject(err);
}
resolve(...result);
}]);
});
};
};

Um nun eine Callback nutzende Funktion auf Grundlage eines Promise zu verwenden, ist sie einmalig mit promisify in eine entsprechende Funktion zu verpacken:

let fsReadFile = promisify(fs, 'readFile');

fsReadFile('/etc/passwd').then(data => {
// ...
}).catch(err => {
// ...
});

Da sich Promises aneinander hängen lassen, können Ketten aus then-Funktionen entstehen, an deren Ende ein einziger Aufruf von catch genügt, um Fehler zu behandeln. Das löst zwar das Problem der sogenannten Callback Hell, macht den asynchronen Code allerdings nicht lesbarer.

Darüber hinaus lassen sich die klassischen Konstrukte zur Ablaufsteuerung nach wie vor nicht einsetzen und Fehler können weiterhin untergehen, sollten die Entwickler den Aufruf von catch vergessen haben.

Daher lässt sich das ursprüngliche Ziel, den Code kürzer und besser lesbar zu machen, kaum als erreicht ansehen.

Neben Promises enthält ES2015 zwei weitere neue Sprachmerkmale, die im Kontext der asynchronen Programmierung interessant sind. Gemeint sind zum einen sogenannte Generatorfunktionen, zum anderen das Schlüsselwort yield.

Die Idee hinter Letzterem ist, das Ausführen einer Funktion zu unterbrechen, um einen bereits berechneten Wert aus einer ganzen Reihe zu berechnender Werte vorzeitig zurückgeben zu können. Als Beispiel sei die Berechnung von Primzahlen genannt, da die Aufgabe für große Zahlen zeitaufwendig ist:

let isPrimeFactor = function (factor, number) {
return number % factor === 0;
};

let isPrime = function (candidate) {
if (candidate < 2) {
return false;
}

for (let factor = 2; factor <= Math.sqrt(candidate); factor++) {
if (isPrimeFactor(factor, candidate)) {
return false;
}
}

return true;
};

let getPrimes = function (min, max) {
let primes = [];

for (let candidate = min; candidate <= max; candidate++) {
if (isPrime(candidate)) {
primes.push(candidate);
}
}

return primes;
}

Ruft man die Funktion getPrimes mit kleinen Zahlen und einem kleinen Intervall auf, gibt sie das gewünschte Ergebnis rasch zurück:

let primes = getPrimes(1, 20);
// => [ 2, 3, 5, 7, 11, 13, 17, 19 ]

Für größere Werte und Intervalle rechnet die Funktion jedoch abhängig von den gewählten Zahlen durchaus einige Sekunden. Es wäre hilfreich, bereits berechnete Primzahlen ausgeben zu können, während die Berechnung der übrigen noch läuft.

Genau das ermöglicht das Schlüsselwort yield. Es verhält sich prinzipiell wie die return-Anweisung, speichert aber den Zustand der Funktion, sodass sie sich zu einem späteren Zeitpunkt fortsetzen lässt. Allerdings ist es nicht möglich, das Schlüsselwort in jeder beliebigen Funktion zu verwenden, sondern nur in Generatorfunktionen. Sie werden in JavaScript mit function * statt function definiert:

let getPrimes = function * (min, max) {
for (let candidate = min; candidate <= max; candidate++) {
if (isPrime(candidate)) {
yield candidate;
}
}
}

Ruft man eine Generatorfunktion auf, führt sie im Gegensatz zu einer normalen Funktion nicht den in ihr enthaltenen Code aus, sondern gibt zunächst ein Iteratorobjekt zurück. Auf ihm lässt sich dann die Funktion next aufrufen, um die eigentliche Funktion auszuführen – allerdings nur bis zum ersten Aufruf von yield:

let iterator = getPrimes(1, 10);

console.log(iterator.next());
// => { value: 2, done: false }

Ruft man die next-Funktion erneut auf, setzt das Programm die Ausführung der Funktion fort, bis es auf ein weiteres yield oder das Ende des auszuführenden Codes stößt:

let iterator = getPrimes(1, 10);

console.log(iterator.next()); // => { value: 2, done: false }
console.log(iterator.next()); // => { value: 3, done: false }
console.log(iterator.next()); // => { value: 5, done: false }
console.log(iterator.next()); // => { value: 7, done: false }
console.log(iterator.next()); // => { value: undefined, done: true }

Um den Umgang mit Iteratoren zu vereinfachen, kennt ES2015 die for-of-Schleife, die einen Iterator erzeugt und durchläuft:

for (let prime of getPrimes(1, 10)) {
console.log(prime);
}
// => 2, 3, 5, 7

Besonders interessant ist, dass man der next-Funktion wiederum Parameter übergeben kann, die in der getPrimes-Funktion als Rückgabewert von yield zur Verfügung stehen. Damit lässt sich beispielsweise eine Schleife zur Berechnung unendlich vieler Primzahlen schreiben, die von außen abgebrochen werden kann:

let getPrimesFrom = function * (min) {
for (let candidate = min; ; candidate++) {
if (isPrime(candidate)) {
let shallContinue = yield candidate;

if (!shallContinue) {
return;
}
}
}
}

Nun lässt sich beispielsweise die Verarbeitung beenden, sobald fünf Primzahlen berechnet wurden. Der erste Aufruf von next nimmt dabei noch keinen Parameter entgegen, da er das Ausführen der Funktion überhaupt erst startet und daher noch kein yield erreicht wurde, dem sich ein Rückgabewert übergeben ließe:

let primesIterator = getPrimesFrom(1);
console.log(primesIterator.next()); // => { value: 2, done: false }
console.log(primesIterator.next(true)); // => { value: 3, done: false }
console.log(primesIterator.next(true)); // => { value: 5, done: false }
console.log(primesIterator.next(true)); // => { value: 7, done: false }
console.log(primesIterator.next(true)); // => { value: 11, done: false }
console.log(primesIterator.next(false)); // => { value: undefined, done: true }