Railway Oriented Programming in Java

Monaden

Anzeige

Das vorige Beispiel ist leider noch nicht ganz praxistauglich. Immerhin kann es ausschließlich mit Strings arbeiten. Für echte Projekte ist eine Möglichkeit zu schaffen, beliebige Datentypen für Wert und Fehler verwenden zu können. Die Klasse enthält auch noch keinerlei Fehlerbehandlung. So lässt sich jederzeit mit getError() auf den Fehler zugreifen, selbst wenn dieser gar nicht vorhanden (also "null") ist. Außerdem wären noch ein paar weitere Methoden hilfreich, die häufige Anforderungen umsetzen (etwa die Überprüfung oder die Verarbeitung und Umwandlung des Werts in einen anderen Datentypen).

Ein Ansatz ist, Result in eine generische Klasse umzubauen, die sich mit zwei Datentypen für ihren Wert und ihren Fehler parametrisieren lässt. Außerdem sollten die beiden unterschiedlichen Zustände Success und Failure objektorientiert in eigenen Subklassen modelliert werden. Da der resultierende Code etwas umfangreicher ausfällt als das obige Beispiel, sei an der Stelle darauf verzichtet, ihn in seiner Gesamtheit zu zeigen. Das vollständige Projekt inklusive Unit-Tests können die Leser auf GitHub einsehen.

Die fertige Klasse Result<TSuccess, Tfailure> kann mit beliebigen Datentypen für Wert und Fehler arbeiten und bietet mehrere Methoden an, die es ermöglichen, sie analog zu bekannten Konstrukten aus Java 8 wie java.util.Optional zu verwenden. Optional repräsentiert auch das Ergebnis einer Operation. Allerdings wird statt des erfolgreichen oder fehlerhaften Ausgangs hier dargestellt, ob die Operation einen Wert zurückliefert oder nicht. Das Lesen eines Benutzers aus der Datenbank zu einer ID könnte zum Beispiel ein Optional<Benutzer> zurückgeben, da nicht zwangsläufig ein Benutzer mit der angegebenen ID existieren muss. Der fehlende Benutzer ist aber auch kein Fehlerfall, sondern ganz "normal".

Beide Konstrukte – Optional und Result – implementieren ein abstraktes Konzept: die Monade. Darunter versteht man einen Container für Datentypen, der bestimmte Operationen anbieten und gewissen Regeln folgen muss. Ist das der Fall, lassen sich mehrere Monaden einfach zu komplexen Operationen verketten, wie das im dritten Quellcode-Beispiel zu sehen ist.

Dieser Artikel soll keine wissenschaftliche Einführung in die mathematische Kategorientheorie sein, in der die Monaden ihren Ursprung haben. Vielmehr sollen im Folgenden nur die für die Programmierung relevanten Eigenschaften von Monaden betrachtet werden, die aus drei Funktionen bestehen:

  1. ein Typkonstruktor, der die Monade mit einem Datentypen parametrisiert – im Beispiel Optional<T> bzw.Result<TSuccess, Tfailure>.
  2. eine Funktion, die aus einem normalen Datentyp eine Monade des Datentyps macht, ihn also in der Monade einpackt. Die Funktion heißt in funktionalen Programmiersprachen zum Beispiel return() oder unit(). Beispiel: Optional.of(5) macht aus dem Integer 5 ein Optional<Integer>.
  3. eine Funktion, die eine andere Funktion als Parameter bekommt, die aus dem inneren Datentyp der Monade eine neue Monade eines beliebigen Datentyps erzeugt und diese neue zurückgibt. Der in der ursprünglichen Monade enthaltene Datentyp wird ausgepackt, die übergebene Funktion auf seinen Wert angewendet und das Ergebnis der Operation – wiederum eine Monade, aber gegebenenfalls mit einem anderen Datentyp – zurückgegeben. In funktionalen Sprachen heißt diese Funktion etwa bind() oder flatMap(). Beispiel: Optional.of("text").flatMap(text -> Optional.of(text.length())) liefert als Ergebnis ein Optional<Integer>. Die an flatMap() übergebene Funktion (der Lambda-Ausdruck) bekommt einen String als Parameter und sendet ein Optional<Integer> zurück. flatMap() nimmt also den im Optional enthaltenen String mit dem Wert text, wendet die übergebene Funktion auf ihn an, erhält als Ergebnis ein Optional<Integer>, das die Länge des Strings (4) enthält, und gibt es dann zurück.

Die obigen Funktionen müssen gewisse Regeln einhalten, damit sich von einer echten Monade sprechen lässt. Zum Beispiel muss sich die unit-Funktion als neutrales Element der bind-Funktion verhalten. Wer Interesse an den mathematischen Hintergründen hat, dem sei der entsprechende Wikipedia-Artikel zum Einstieg empfohlen.

Im folgenden Codebeispiel sind die obigen Funktionen am Beispiel von Result zu sehen. withError() und withValue() sind die unit()-Funktionen und verpacken den übergebenen Wert in ein Result, das entweder aus einem Objekt der Subklassen Success oder Failure besteht. flatMap() ist dann polymorph in den beiden Subklassen implementiert und führt die übergebene Funktion im Erfolgsfall aus beziehungsweise macht im Fehlerfall einfach nichts (aufgrund des gegebenenfalls wechselnden Datentypen von TSuccess zu T ist jedoch ein neues Failure-Objekt zu erstellen).

// in Basisklasse Result
public static <TSuccess, TFailure> Result<TSuccess, TFailure>
withError(final TFailure error) {
assertParameterNotNull(error, "Error");
return new Failure<>(error);
}

public static <TSuccess, TFailure> Result<TSuccess, TFailure>
withValue(final TSuccess value) {
assertParameterNotNull(value, "Value");
return new Success<>(value);
}

// in Subklasse Success
public <T> Result<T, TFailure> flatMap(final Function<TSuccess,
Result<T, TFailure>> function) {
return function.apply(getValue());
}

// in Subklasse Failure
public <T> Result<T, TFailure> flatMap(final Function<TSuccess,
Result<T, TFailure>> function) {
return new Failure<>(getError());
}

Aufgrund der vielen Typparameter sieht die Implementierung etwas wild aus, aber der eigentliche Code ist relativ trivial. Die gesamte Klasse Result ist nur circa 50 Zeilen lang (s. Result) und unterstützt noch deutlich mehr Funktionen als die bisher genannten.

In Java 8 gibt es mehrere eingebaute Monaden. Sie alle stellen einen Datentyp in einen operationalen Kontext, dessen teils hohe Komplexität dadurch vor dem Aufrufer verborgen bleibt. Stattdessen lässt sich einfach die einheitliche Schnittstelle der Monaden nutzen, um unterschiedliche Konzepte im Code ähnlich zu verwenden. Die folgende Liste fasst drei dieser Monaden und die in diesem Artikel vorgestellte zusammen.

  • Optional: repräsentiert das Nicht-/Vorhandensein eines Werts und vermeidet ausufernde null-Checks.
  • Stream: repräsentiert potenziell mehrere Werte und vermeidet Iterationen.
  • Promise: repräsentiert das Ergebnis einer asynchronen Operation und vermeidet Thread-Programmierung.
  • Result: repräsentiert das potenziell fehlerhafte Ergebnis einer Operation und vermeidet Exception Handling.
Anzeige