Functional programming – Teil 7
Es wird Zeit, dass wir über Zeit reden. Na ja, eigentlich eher um Asynchronität und wie wir damit umgehen.
Ja, ich weiss, Promises sind eine Möglichkeit damit umzugehen. Bedauerlicher Weise sind Promises nicht optimal für den Einsatz in der funktionalen Programmierung.
Bevor wir uns dem Vergleich widmen, sehen wir uns erstmal eine Alternative an.
Task
Eine Task verhält sich auf den ersten Blick sehr ähnlich zu unserem Either.
Wir müssen uns beim Auslösen gleichermaßen um den Fehler- wie um den Erfolgsfall kümmern.
Hinweis:
Zum Zeitpunkt dieses Artikels gibt es zwar bereits eine neuere Version von Task. Diese ist jedoch noch als experimentell gekennzeichnet und wird daher hier nicht behandelt.
const plusOne = x => x + 1
const onError = e => console.log('error', e)
const onSuccess = x => console.log('success', x)
// acts like Right
Task.of(1).map(plusOne).fork(onError, onSuccess) // -> success 2
// acts like Left
Task.rejected(1).map(plusOne).fork(onError, onSuccess) // error 1
Auch hier gibt es eine Verzweigung, die es uns erlaubt sorglos drauf los zu mappen (oder zu chainen). Im Fehlerfall wird nichts davon ausgeführt.
Nebeneffekte
Abgesehen von den beiden Typkonstruktoren (of und rejected) können wir eine Task auch auf anderem Weg erzeugen was uns in die Lage versetzt die gefürchteten Nebeneffekte einzufangen.
const dropDatabase = () =>
new Task((reject, resolve) => {
db.drop()
resolve('database dropped')
})
Tasks sind lazy wodurch sie erst aktiv werden, wenn man sie mittels fork auslöst.
dropDatabase() // will not run
dropDatabase().fork(onError, onSuccess) // -> success database dropped
Um bei unserem Beispiel zu bleiben würden wir dropDatabase
in unserer Bibliothek platzieren und es dem Nutzer der Bibliothek überlassen sich mit den konkreten Effekten zu befassen. Denn damit überhaupt etwas passiert muss der Nutzer fork
einsetzen.
Auf diese Weise können wir alle möglichen Effekte als reine Funktionen abbilden.
Praktische Beispiele
Operationen im Dateisystem
const fs = require('fs')
const readFile = (filename, enc = 'utf-8') =>
new Task((reject, resolve) =>
fs.readFile(filename, enc, (err, content) =>
err ? reject(err) : resolve(content)))
const writeFile = (filename, contents) =>
new Task((reject, resolve) =>
fs.writeFile(filename, contents, (err, success) =>
err ? reject(err) : resolve(contents)))
const copyFile = (source, dest, enc = 'utf-8') =>
readFile(source, enc)
.chain(contents =>
writeFile(dest, contents))
module.exports = {
readFile,
writeFile,
copyFile
}
HTTP Requests
const fetch = require('node-fetch')
const responseToText = res => res.text()
const responseToJson = res => res.json()
const httpGet = transform => url =>
new Task((reject, resolve) =>
fetch(url)
.then(transform)
.then(resolve)
.catch(reject))
const getHtml = httpGet(responseToText)
const getJson = httpGet(responseToJson)
module.exports = {
getHtml,
getJson
}
All diese Funktionen bieten automatische Erweiterbarkeit an, da unser Nutzer der Bibliothek die Komposition fortführen kann bevor er letztlich forked.
// automatically composable
getJson('https://dog.ceo/api/breeds/list/all')
.map(prop('message'))
.fork(console.error, console.log)
Promise
Zurück zum Promise, wie versprochen.
Promises sind eager und werden bei Aufruf sofort ausgeführt wodurch wir die Fähigkeit verlieren, Nebeneffekte einzufangen.
Promises sind weder Monad noch Functor. Wir haben weder map
noch chain
wodurch Promises bereits durch das Raster fallen.
Selbst wenn wir unserem Promise ein chain
spendieren welches wir auf then
zeigen lassen, so erfüllt then
die monadischen Gesetzmässigkeiten, die wir letztes mal besprochen haben, nur bedingt.
Im Wesentlichen hängt das damit zusammen, dass ein Promise welches ein weiteres Promise als Wert erhält, dieses innere Promise bereits auf seinen tatsächlichen Wert auflöst.
// Functor
Id.of(1).map(x => Id.of(x + 1)) // Identity (Identity (2))
// Promise
Promise.resolve(1).then(x => Promise.resolve(x + 1)) // Promise (2)
// Monad
Id.of(1).chain(x => x + 1) //=> 2
// Promise
Promise.resolve(1).then(x => x + 1) //=> Promise (2)
Das ist einerseits sehr bequem, da die Verschachtelung immer genau ein Promise tief ist. Andererseits wird es dadurch unmöglich eine Funktion an then
zu übergeben, die ihrerseits ein Promise als argument erwartet.
Aber jeder nutzt Promises!
Da spricht auch überhaupt nichts gegen. Wir müssen lediglich berücksichtigen, dass then
weder als functor noch als monad durch geht.
Erfreulicher Weise sind Task und Promise isomorph und können damit ohne Informationsverlust beliebig hin und her konvertiert werden.
const promiseToTask = p =>
new Task((reject, resolve) =>
p.then(resolve).catch(reject))
const taskToPromise = t =>
new Promise((resolve, reject) =>
t.fork(reject, resolve))
Wir können also bedarfsgerecht von einem zum anderen wechseln.
Keine Kommentare