Kolumne: Olis bunte Welt der IT

JavaScript im Land der Fantasie: Promises als Monaden?
Keine Kommentare

Im April 2013 fand eine interessante Diskussion im GitHub Repository für die Planung der Spezifikation Promises/A+ statt. Es wurde vorgeschlagen, dass die für JavaScript zu spezifizierenden Promises als Monaden erkannt und Promises/A+ entsprechend geändert bzw. ergänzt werden sollte. Im Detail waren die Vorschläge für die Änderungen sehr einfach, wurden jedoch von Domenic Denicola kurzerhand für unbrauchbar erklärt.

„Yeah, this is really not happening“, schrieb Herr Denicola, „It totally ignores reality in favor of typed-language fantasy land …“. Zu Deutsch: Das wird ganz sicher nicht passieren, der Vorschlag ignoriert die Realität vollständig zugunsten eines Fantasielandes von typisierten Sprachen. Die lange Diskussion, die auf diesen kurzen Austausch folgte, ist als unterhaltsame Lektüre für regnerische Nachmittage durchaus empfehlenswert. Es lassen sich daraus mehrere Dinge lernen:

  • Promises sind Monaden, oder können zumindest als solche betrachtet werden.
  • Es gibt zu viele Liebhaber funktionaler Programmierung, die nicht in der Lage sind, einen Zusammenhang ohne Erwähnung komplexer mathematischer Zusammenhänge zu erklären.
  • Auf der anderen Seite gibt es zu viele Menschen, die Argumente anderer nicht im Detail bedenken, wenn sie mit der grundsätzlichen Aussage nicht einverstanden sind.
  • Schockierenderweise waren einige der zuletzt erwähnten Menschen mit der Erstellung von Promises/A+ beschäftigt.

Die Einstellung von Domenic Denicola, mit der alles anfing (und Promises/A+ letztlich endete), ist natürlich bedauerlich, besonders im Rückblick. Ganz offensichtlich hatte der Mann gar nicht verstanden, worum es bei dem Vorschlag eigentlich ging. Mir kamen beim Lesen Zweifel, ob er überhaupt die Idee von Monaden verstanden hatte. Eigentlich waren diese im Jahr 2013 schon seit langem kein Thema nur für funktionale Puristen mehr. In .NET etwa waren die monadischen Aspekte des LINQ API spätestens seit 2008 Gegenstand mancher Diskussionen und erhellender Präsentationen. Allerdings war Domenic Denicola offenbar besonders auf einen völlig anderen Aspekt fixiert: Er meinte, erkannt zu haben, dass die fraglichen Änderungen nur für (strikt?) typisierte Programmiersprachen sinnvoll seien. Das stimmt eigentlich gar nicht, trägt aber dazu bei, dass die weitere Entwicklung heute als besonders unglücklich zu betrachten ist, nachdem TypeScript und andere semitypisierte Ansätze für JavaScript sehr erfolgreich sind.

Das Fantasy Land

Glücklicherweise ergaben sich viele positive Entwicklungen aus Denicolas Kommentar, und die erste solche Entwicklung wurde nach nur wenigen Tagen von Brian McKenna, dem Autor der Änderungsvorschläge, angekündigt: die Fantasy Land Specification. Diese Spezifikation liegt mittlerweile in der vierten Version vor und wird von zahlreichen Projekten für JavaScript und TypeScript implementiert. Diese Projekte bieten Datentypen an, wie sie aus der funktionalen Programmierung bekannt sind, oder machen vorhandene Datentypen kompatibel mit den Anforderungen der Spezifikation.

International JavaScript Conference

Effective Microservices Architecture In Node.js

by Tamar Stern (Palto Alto Networks)

React Components And How To Style Them

by Jemima Abu (Telesoftas)

API Summit 2020

API-Design? Schon erledigt! – Einführung in JSON:API

mit Thilo Frotscher (Freiberufler)

Praktischer Einstieg in Go

mit Frank Müller (Loodse GmbH)

Um zu erklären, was der Sinn einer solchen Bestrebung ist, gehe ich noch einmal einen Schritt zurück zu den Vorschlägen für Promises/A+. Wie bereits erwähnt stand dort im Mittelpunkt, dass Promises als Monaden anerkannt werden sollten. Dieser kurze Satz ist nicht so einfach zu verstehen, wie man es gern hätte – vielleicht wissen Sie insgeheim auch nicht ganz genau, was das bedeutet. Was ist denn nochmal eine Monade? In der Sprache Haskell, die maßgeblich zur Definition der Monade beigetragen hat, ist eine Monade eine bestimmte Typklasse. Eine Typklasse wiederum ist einem Interface in einer objektorientierten Sprache ähnlich. Mit anderen Worten: Eine Typklasse definiert, welche Eigenschaften und welches Verhalten andere Typen gemeinsam haben. Im Gegensatz zum Interface kann allerdings eine Typklasse auch gleich eine Implementation des besagten Verhaltens mitbringen. Noch wichtiger ist, dass beliebige Typen im Nachhinein und „von außen“ mit einer Typklasse assoziiert werden können. Zum Beispiel gibt es in Haskell eine Typklasse Eq, zu der alle Typen gehören, deren Instanzen auf Gleichheit geprüft werden können. Die Typklasse Ord fasst typische numerische Vergleiche zusammen, also „größer“ und „kleiner“ sowie darauf aufbauende Varianten. Wenn Sie selbst einen Typ programmiert haben, können Sie für diesen eine Instanz dieser Typklassen erzeugen, so dass Ihr Typ offiziell vergleichbar ist. Für einen Typ aus einer Library, die jemand anderes geschrieben hat, können Sie diese Typklasseninstanz bei Bedarf nachrüsten. Der Compiler spielt bei diesem System auch mit und leitet Typklassen anhand der verwendeten Operationen automatisch her.

Nebenbei: Dieses Konzept wird von .NET leider nicht unterstützt und kann daher auch nicht von Sprachen implementiert werden, die auf .NET laufen. F# kann tolle Typherleitung und C# 8.0 unterstützt nun Traits mit Hilfe von Methoden in Interfaces, aber für Typklassen reicht das leider noch nicht aus.

Was macht eine Monade?

Zurück zur Monade: Diese schreibt vor, dass ein assoziierter Typ die Fähigkeit haben soll, einen Wert zu kapseln. Das trifft offensichtlich auf viele Typen zu, unter anderem auf Promises – ein Promise ist ein Typ, der verspricht, auf Anfrage einen Wert zu liefern. Die zweite Anforderung ist, dass ein monadischer Typ eine Verkettungsoperation unterstützen muss, mit deren Hilfe eine Verarbeitung des enthaltenen Werts innerhalb des monadischen Systems möglich ist. In Bezug auf die Promise: Ich sollte eine Funktion auf den Wert des Promise anwenden können und dann wiederum ein Promise erhalten. Klar, das kann ich auch:

const promiseEncapsulatedValue = Promise.resolve(21);
const chainedComputation = promiseEncapsulatedValue.then(x =>
  Promise.resolve(x * 2)
);

Es ist einfach zu erkennen, dass das Promise grundsätzlich die Anforderungen einer Monade erfüllt. In der erwähnten Diskussion zur Spezifikation ging es im Detail darum, in welcher Weise diese Anforderungen nicht erfüllt werden. Damals wurde tatsächlich nicht festgelegt, wie der Wert eigentlich in das Promise hineingelangt – das ist schon merkwürdig. Der zweite Kritikpunkt war aber wichtiger: Promises/A+ legt fest, dass jedes Objekt mit einer Methode then als Promise betrachtet werden darf, und dass eine Menge dynamisches Verhalten in der then-Methode erwartet wird. Wenn etwa der Rückgabewert gar keine Promise ist, wird einfach ein neues erzeugt. Außerdem hat then eigentlich zwei Parameter, da gleichzeitig auch Fehlerfälle verarbeitet werden. Ich überlasse Ihnen die Lektüre der entsprechenden Diskussionen und Artikel sowie den Vergleich mit dem Stand 2019. Der Artikel „Broken Promises“ von 2017 erklärt einige der Probleme, die aus den grundlegenden Designentscheidungen entstanden.

Monadische Typen explizit als solche zu erkennen und zu handhaben bringt viele Vorteile mit sich, insbesondere weil konsistenter Code nach dem Prinzip DRY (don’t repeat yourself) für den Umgang mit solchen Typen geschrieben werden kann. In C# ist etwa das LINQ API monadisch, wie Sie z. B. an dieser Signatur der Standardfunktion SelectMany sehen können:

public static IEnumerable<TR> SelectMany<TS, TR>(
  this IEnumerable<T>,
  Func<TS, IEnumerable<TR>> selector
)

Bei der Funktion handelt es sich um die Verkettungsoperation, die oben beschrieben wurde. Die Monade ist IEnumerable<T>, ein Typ, der einen Wert vom Typ T kapselt. Der Parameter selector in SelectMany ist eine Funktion, die einen Wert vom Typ T entgegennimmt und eine neue Monade zurückliefert. Sie wissen, wie viele Funktionen es heute in .NET selbst und auch in Libraries von Dritten gibt, die auf IEnumerable<T> basieren. Was sich in .NET und C# nicht so einfach erkennen lässt, ist, dass dieselben Ansätze auch für andere Typen als die üblichen Wertsequenzen, mit denen LINQ arbeitet, anwendbar sind.

In funktionalen Sprachen gibt es oft einen Typ Maybe (manchmal auch Option genannt), der einen eventuell nicht vorhandenen Wert darstellt. Auch der Typ Either ist sehr verbreitet, er kann einen Wert zusammen mit einer Klassifikation enthalten. Either wird oft zur Darstellung von Werten verwendet, bei deren Erzeugung es eventuell zu Fehlern kommen kann. Etwa so:

const calc = x => y => 
  (y !== 0 ? 
    Right(x / y) : 
    Left("Can't divide by zero"));

Right und Left sind typische Namen für die Klassifikationen, sie dürfen für dieses Beispiel Right ganz im Sinne von „richtig“ verstehen. Der wichtigste Punkt für Monaden besteht darin, dass diese beiden (und viele andere Typen) dieselben Anforderungen haben wie Promises. Wenn Sie z. B. einmal eine Operation begonnen haben, die eventuell einen Fehler liefert, dann muss die Fehlerinformation im weiteren Verlauf natürlich immer beibehalten werden. In anderen Worten: Wenn wir einmal ein Either haben, müssen alle weiteren Vorgänge im Kontext von Either ablaufen. Das ist bei Promises genauso, und deshalb sollte es für Either, Maybe, Promise und andere ein konsistentes API geben.

Nun ist das Land der Fantasie längst Wirklichkeit geworden. Ich selbst verwende in einigen Projekten die Library Sanctuary, die der Fantasy Land Specification gehorcht und außerdem über hilfreiche Mechanismen zur Laufzeittypprüfung verfügt. Als aufmerksame Leser wissen Sie, dass ich kein Fan von TypeScript bin die Typisierung in Sanctuary funktioniert ganz anders und ist außerdem auf Zuruf abschaltbar (sowie im Production-Modus ganz ohne Overhead). Listing 1 zeigt ein kurzes Beispiel.

const getConfigValue = name => json =>
  S.map
    (map =>
      S.maybe
        ('<placeholder>')
        (S.I)
        (S.value
          (name)
          (map))
    )
    (S.encase
      (JSON.parse)
      (json));

Die Funktion getConfigValue bekommt den Namen (name) eines Elements übergeben, das sie aus dem JSON-Inhalt (json) auslesen soll. Sie ruft dazu zunächst JSON.parse auf, das allerdings mit Hilfe von encase in eine Funktion umgewandelt wird, die ein Either liefert. Sollte also der JSON-Inhalt ungültig sein, wird ein Left(<Fehlermeldung>) erzeugt, andernfalls ein Right(Inhalt). Der Aufruf an S.map ist analog zum oben beschriebenen then bei Promises. Für gültige Right-Werte wird die innere Funktion aufgerufen, in der mit S.value der gewünschte Wert aus dem JSON-Objekt ausgelesen wird. S.Value liefert allerdings ein Maybe, denn es kann ja sein, dass der Wert nicht im Objekt steht. S.maybe (kleines M) wertet das Resultat aus und setzt <placeholder> ein, falls der Wert nicht gefunden wurde. Andernfalls sorgt S.I (die Identity-Funktion, die den übergebenen Wert zurückliefert) dafür, dass der gefundene Wert zum Resultat wird. Schließlich benimmt sich die äußere Funktion S.map monadisch korrekt und kapselt das neue Resultat wieder in einem Right.

Im Test liefert die Funktion Ergebnisse wie in Listing 2 gezeigt.

> getConfigValue
  ('thing')
  ('{ "thing": "a thing" }');

Right ("a thing")

> getConfigValue
  ('other')
  ('{ "thing": "a thing" }');

Right ("<placeholder>")

> getConfigValue
  ('thing')
  ('{ INVALID "thing": "a thing" }');

Left (new SyntaxError ("Unexpected token I in JSON at position 2"))

Anhand dieser Funktion lässt sich auch die Typprüfung demonstrieren. Die Funktion maybe geht davon aus, dass der Platzhalterwert (das erste Argument) denselben Typ hat wie der Rückgabewert des anderen Ausführungspfades. Das entnehmen Sie mit etwas Übung der Signatur der Funktion in der Dokumentation:

maybe :: b -> (a -> b) -> Maybe a -> b

Darin sind die beiden Typvariablen a und b erkennbar. Das erste b bezeichnet den Typ des Platzhalters, während das b in der geklammerten Signatur der Funktion den Rückgabewert darstellt. Testweise gebe ich einen Wert von einem anderem Typ zurück:

...
S.maybe
  (42)
  (S.I)
  (S.value
    (name)
    (map))
...

Sanctuary meldet diesen Verstoß so:

Type-variable constraint violation 

maybe :: b -> (a -> b) -> Maybe a -> b 
         ^          ^ 
         1          2 

1)  42 :: Number 
2)  "a thing" :: String 

Since there is no type of which all the above values are members, the type-variable constraint has been violated.

Wie Sie sehen, ist diese Meldung höchst aussagekräftig und erfordert keinerlei Deklaration der Typen seitens des Programmierers.

In JavaScript als dynamischer Sprache kommt es natürlich vor, dass das Objekt map tatsächlich Werte unterschiedlicher Typen enthält. In diesem Fall kann die Typprüfung selektiv für den Aufruf an maybe ausgeschaltet werden, indem ich einfach S.unchecked.maybe aufrufe. Dabei ist unchecked ein automatisch eingerichteter alternativer Kontext, in dem Typen nicht geprüft werden. Dieses Konzept ist erweiterbar und ich kann selbst solche Kontexte nach Bedarf erzeugen und konfigurieren.

Auch den Einsatz einer echten Fantasy-Land-konformen Promise-Alternative können Sie mit dem Paket Fluture ausprobieren. Die Futures, die Sie so erzeugen können, lassen sich mit Hilfe der Werkzeuge aus Sanctuary weiterverarbeiten. Auch die Brücke zur Promise-Welt ist schnell gebaut, da entsprechende Hilfsfunktionen enthalten sind. Listing 3 zeigt hierfür ein Beispiel.

const getData = S.pipe
  ([
    F.encaseP
      (fetch),
    S.chain
      (res =>
        res.ok
          ? F.attemptP
              (res.json.bind
                (res))
          : F.reject
              (`HTTP error ${res.status}: ${res.statusText}`)
      ),
    S.map
      (S.value
        ('data'))
  ]);

Den Kern der Funktion getData bilden die Aufrufe in die Library Fluture, gekennzeichnet durch das F. encaseP ist eine Funktion, die aus dem Promise fetch ein Future macht. Die Pipe, die von Sanctuary erzeugt wird, liefert letztlich den URL als Parameter an fetch. Der zweite Schritt der Pipe ist die Auswertung des Rückgabewertes von fetch. Er resultiert wiederum in einem Future, diesmal unter Verwendung von F.attemptP (eine Variante von encaseP für Fälle, in denen keine Argumente übergeben werden) bzw. dem selbst erklärenden F.reject. Den letzten Schritt habe ich für dieses Beispiel spezifisch hinzugefügt, da die Datenstruktur ein Feld data auf der obersten Ebene haben wird – logisch gehört dieser Schritt wahrscheinlich an eine andere Stelle, aber ich wollte damit deutlich machen, wie die Futures in einer Verarbeitungspipeline ihren Platz finden, die ansonsten auf Typen aus Sanctuary besteht. Dieses konsistente Zusammenspiel wird durch die funktionalen Standards der Fantasy Land Specification möglich.

Um zu Demonstrationszwecken die Daten etwas weiter zu verarbeiten, habe ich in Listing 4 eine Funktion calc vorbereitet.

const calc = S.compose
  (S.justs)
  (S.map
    (item =>
      S.unchecked.sequence
        (S.Maybe)
        ({
          name: S.unchecked.value
            ('name')
            (item),
          populationPerKM2: S.lift2
            (S.div)
            (S.filter
              (x => x > 0)
              (S.unchecked.value
                ('areaKM2')
                (item)))
            (S.unchecked.value
              ('population')
              (item))
        })
    ));

Die Daten, die von diesem Algorithmus verarbeitet werden, haben folgendes Schema:

[
  {
    _id: '58060596392c9a92f2f86222',
    name: 'American Samoa',
    areaKM2: 199,
    population: 57880
  },
  ...
]

Im Code können Sie sehen, dass die einzelnen Datenfelder mit S.unchecked.value ausgelesen werden, wie ich es schon vorher beschrieben habe. So ergeben sich Maybe-Werte und der Wert areaKM2 wird außerdem durch filter auf korrekte positive Zahlen beschränkt. Dieses kleine Detail ist höchst interessant, denn vom JavaScript-Array und seiner Methode filter ist man gewohnt, dass sie nur mit Listen arbeitet. In ordentlichen funktionalen Sprachen jedoch gibt es eine Typklasse Filterable, die all solche Typen zusammenfasst, die gefiltert werden können. Sanctuary unterstützt diesen Ansatz und Maybe ist ein filterbarer Typ – zum Beispiel liefert S.filter(x => x > 0)(S.Just(0)) ein S.Nothing.

Für die Berechnung des Wertes populationPerKM2 wird natürlich eine Division benötigt, und die entsprechende Hilfsfunktion S.div wird mithilfe von S.lift2 so erweitert, dass sie automatisch nur dann die Division durchführt, wenn die Argumente S.Just sind – sollte eines der Argumente ein S.Nothing sein, ist auch das Resultat S.Nothing. Auf solche Weise sind die beschriebenen Techniken in den funktionalen Standardfunktionen verankert, und Sie müssen nur sehr selten Code schreiben, der etwa explizit auf S.Just oder S.Nothing testet.

Nachdem ein Datenobjekt verarbeitet worden ist, entsteht dieses Schema:

{
  name: Just('string'),
  populationPerKM2: Just(number)
}

Die enthaltenen Werte sind also in Maybe gekapselt. Mit dem Aufruf S.unchecked.sequence wird dieses Schema umgebaut:

Just({
  name: 'string',
  populationPerKM2: number
})

Sollte sich unter den Feldern des Objekts eines finden, das S.Nothing enthält, wird das Ergebnis von sequence ebenfalls S.Nothing. Durch den Aufruf an S.map wird nun aus den Elementen der ursprünglichen Liste eine Liste von Maybes, die letztlich mit S.justs gefiltert wird, um eventuell ungültige Einträge zu eliminieren und die Maybe-Kapselung zu entfernen. Der Vollständigkeit halber sei noch S.compose erwähnt, das „normale“ funktionale Komposition anwendet: compose(f)(g) erzeugt eine Funktion f(g()).

Damit kann ich nun das Beispiel abschließen. Was noch fehlt, ist ein Aufruf des Future. Wenn Sie einige der verlinkten Artikel gelesen haben, ist Ihnen sicher aufgefallen, dass die Angewohnheit des Promise, bei Initialisierung sofort loszulaufen, negativ zu beurteilen ist. Das Future arbeitet entsprechend nicht so, sondern wird spezifisch ausgewertet, gewöhnlich nur an einer Stelle in einem Programm oder zumindest einem Programmteil. Für den Zweck des Beispiels kann das so aussehen wie in Listing 5.

F.fork
  (x => {
    console.error('Error: ', x);
  })
  (data => {
    console.log('Data: ', data);
  })
  (S.compose
    (S.map(S.map(calc)))
    (getData)('http://outlier.oliversturm.com:8080/countries'));

F.fork ist der Aufruf, der die Auswertung des Future anstößt. Das Future ist dabei der letzte Parameter und wird zunächst von getData geliefert. Wir wissen, dass die Daten aus dieser Funktion zweifach gekapselt herauskommen, in der Form Future(Maybe([data])). Die beiden Aufrufe an S.map entfernen diese Kapselungen, bevor die Daten an calc übergeben werden, und fügen sie wieder hinzu, nachdem calc sein Ergebnis liefert.

Die ersten beiden Parameter für F.fork sind zwei weitere Funktionen, die entweder mit einem Fehlerresultat oder mit dem Ergebnis der Future aufgerufen werden. In einer echten Anwendung würde natürlich an dieser Stelle die restliche Programmlogik einsetzen, die die Daten aus der Future weiter verwendet.

Wenn Sie Interesse haben, die Beispiele selbst nachzuvollziehen, können Sie mit dem Code im GitHub-Repository beginnen. Falls Ihnen Sanctuary noch nicht genug ist, gibt es weitere Möglichkeiten, in JavaScript so richtig funktional zu programmieren. Das Repository Sweet Fantasies enthält einige Makros für Sweet.js, mit deren Hilfe Sie Ihren Code syntaktisch einfacher gestalten können, wenn Sie oft mit funktionalen Elementen arbeiten. Der ECMA-Standard könnte in Zukunft auch einen Pipeline Operator unterstützen und der ist mit Babel schon heute verwendbar, wenn Sie das Plugin für TC39/proposal-pipeline-operator aktivieren. Schließlich gibt es auch immer mehr Interesse an „echten“ funktionalen Umgebungen wie PureScript und Reason, die natürlich wunderbar mit JavaScript zusammenarbeiten. Reason unterstützt dabei als Syntax auch OCaml, sodass Sie den Code womöglich mit wenigen Änderungen mit F# in .NET einsetzen können. Ich wünsche gutes Gelingen!

Windows Developer

Windows DeveloperDieser Artikel ist im Windows Developer erschienen. Windows Developer informiert umfassend und herstellerneutral über neue Trends und Möglichkeiten der Software- und Systementwicklung rund um Microsoft-Technologien.

Natürlich können Sie den Windows Developer über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. Außerdem ist der Windows Developer weiterhin als Print-Magazin im Abonnement erhältlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -