Functional programming – Teil 7

lambda 2 time

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

Deinen Kommentar hinzufügen

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.