Modernes funktionales Programmieren in Java

Erfahrene Java-Entwickler*innen können viele der Entwurfsmuster des „Gang of Four“-Buches aus dem Stand zitieren, identifizieren und anwenden. Trotzdem stehen diese praktisch seit der Erscheinung des Buches unter Kritik: da sie teils vage definiert sind, strotzen „moderne“ Codebasen nur so von Singletons, Factories und Buildern. Auf der anderen Seite stehen die Anhänger*innen der funktionalen Programmierung, die sich gerne über die objektorientierten Patterns lustig machen. Die Wahrheit liegt wie so oft in der Mitte: seit Version 8 lassen sich auch in Java die cleveren Gedanken aus der funktionalen Welt effektiv und komfortabel benutzen, ohne die Objektorientierung aufzugeben.

Was ist eigentlich „Funktionale Programmierung“?

Die Frage, was die Essenz der funktionalen Programmierung ausmacht, hat schon viele Menschen umgetrieben, die Programmiersprachen und Bibliotheken designen. Eine stringente Definition fällt schwer, denn mittlerweile haben sich mehr oder weniger alle verbreiteten, noch weiterentwickelten Sprachen solcherlei Aspekte auf die Fahnen geschrieben. Doch nur die Tatsache, dass man jetzt map und filter auf Arrays und Listen ausführen kann, macht eine Sprache noch nicht funktional.

Des Pudels Kern liegt aber weniger in den Sprachfeatures als solchen, sondern der Philosophie ihrer Verwendung: In der funktionalen Programmierung geht es um die Verwendung und Verknüpfung von Funktionen. Funktionen werden als „first class“-Konstrukt aufgefasst und können gespeichert, umhergereicht und anderen Funktionen als Parameter übergeben werden. Statt Zustand versteckt zu verändern, definiert man Funktionen, die Zustandsübergänge explizit modellieren; ähnlich wie Commits in einem Repository, die eine Abfolge von Patches darstellen.

In der Praxis heißt das, dass man zu einem eher deklarativen Programmierstil übergeht. Der Fokus liegt auf dem Was, nicht auf dem Wie. Unveränderliche Datenstrukturen können genutzt werden, um bei diesem Stil zu unterstützen, sind aber nicht überall zwingend erforderlich.

Für diesen Artikel soll uns eine fiktive persönliche Finanz-App als fachliche Domäne dienen, anhand der man funktionale Konzepte erläutern kann.

Asynchrone Programmierung

Ein plastisches Beispiel für diesen Gesinnungswandel ist die heute allgegenwärtige asynchrone Programmierung. Es ist noch gar nicht so lange her, dass parallele Programmierung hieß, dass man manuell Threads erzeugen und verwalten musste. Voneinander abhängige Berechnungen mussten aufwändig mit Locks, Semaphoren oder ähnlichen Mechanismen synchronisiert werden. Nicht selten hat die scheinbare Parallelisierung zu neuen Flaschenhälsen oder gar fehlerhaften Zuständen geführt.

In Java 7 wurde das bereits 2000 von Doug Lea vorgestellte Fork-Join-Framework eingeführt. Als neue Abstraktion gab es sogenannte „rekursive Tasks“, die, um einen Wert zu berechnen, weitere Child-Tasks starten können. Das Framework kümmert sich um das Verteilen der Tasks auf eine bestimmte Menge von Threads (dem „Thread Pool“). In diesem Programmiermodell gibt es eine konkrete Aufgabenverteilung: das Framework übernimmt die technische Schicht, die Programmiererin die fachliche Schicht.

Einen Pferdefuß hatte das Fork-Join-Framework aber noch: Es ist eher für parallele Algorithmen gedacht und daher nicht besonders gut für I/O-Operationen geeignet.

Mit Java 8 wurde das Konzept der „Tasks“ noch weiter verallgemeinert und mit dem schon etwas länger vorhandenen – aber vorher wenig nützlichen – Interface Future zusammengeführt. Ein Objekt vom Typ CompletableFuture<T> stellt gewissermaßen ein Versprechen dar, dass die Laufzeitumgebung[1] irgendwann ein T berechnet haben wird. Das JDK bietet eine ganze Reihe von Methoden an, um solche CompletableFutures zu erzeugen und bestehende miteinander zu verknüpfen:

// obtain all account transactions from bank API
var transactions = httpClient.sendAsync(...);

var taxes = input.thenComposeAsync(response -> {
  // compute tax burden on stock transactions
});

taxes.thenAccept(result -> {
  // push result to database or UI
});

In diesem Code-Schnipsel wird eine Operation in drei Schritten ausgeführt:

  1. Abruf einer REST-Schnittstelle mittels des asynchronen HTTP-Clients (seit Java 11)
  2. Ausführung eines komplexen Algorithmus auf dem abgerufenen Wert
  3. Speichern des Ergebnisses in der Datenbank (z.B. mit R2DBC)

Alle Schritte sind asynchron, aber nicht parallel. Trotzdem wird kein Thread blockiert, um auf eine Antwort zu warten. Wenn mehrere solche Operationen ausgeführt werden, dann kann das zugrundeliegende Framework zusätzlich für optimale Auslastung der Threads sorgen, in dem es z.B. mehrere HTTP-Anfragen gleichzeitig startet. Das Beste daran: als Programmierer*in braucht man bloß die Rahmenbedingungen zu konfigurieren (z.B. die Größe des Thread- oder Connection-Pools). Der Rest wird vom Framework bzw. dem JDK geregelt.

Klar ist jedoch: Wenn man keine manuelle Kontrolle mehr über die Ausführungsreihenfolge hat, sondern nur noch kausale Zusammenhänge festlegt, ist die Verwendung von Mutable State eine noch größere Fehlerquelle als sonst. Nicht umsonst verbietet das Typsystem der systemnahen Programmiersprache Rust das Sharing von Mutable Pointern über Thread-Grenzen hinweg grundsätzlich.

Die Abhilfe ist jedoch sehr einfach. In zahlreichen Bibliotheken in Java stehen unveränderliche Collections zur Verfügung, z.B. in Guava. Man kann aber auch die JDK-Klassen benutzen und sie in die unmodifiable-Wrapper verpacken.

Async und FP

Was hat das ganze jetzt mit funktionaler Programmierung zu tun? Wir konzentrieren uns bei der Arbeit mit asynchronem Code nicht mehr auf das Wie, sondern nur noch das Was. Geeignete Abstraktionen wie CompletableFuture verbergen die Implementierungsdetails von uns. Doch dass das einen funktionalen Programmierstil verkörpert, kommen noch zwei entscheidende Faktoren hinzu:

  1. CompletableFuture sind ganz gewöhnliche Objekte, die umhergereicht werden können
  2. sie sind miteinander und mit Funktionen verknüpfbar

Verbreitete Frameworks wie Spring Boot und Play unterstützen CompletableFuture nativ und erlauben so mit JDK-Bordmitteln die funktionale asynchrone Programmierung. Für die Applikationsentwicklung bietet das gewichtige Vorteile, da durch das automatische Scheduling höherer Durchsatz und Reaktivität erreicht werden kann. Die zugrundeliegenden I/O-Stacks der Betriebssysteme unterstützen asynchrone Anfragen schon seit geraumer Zeit; es liegt an den Hochsprachen, diese auch auszunutzen.

DDD und FP

Wenn man sich die Domain-Modellierung anschaut, schließt sich der Kreis hin zur Verwendung von unveränderlichen Objekten. Im Domain Driven Design unterscheidet man zwischen verschiedenen Typen von Modellen:

  • Eine Entitity hat eine Identität und einen Lebenszyklus.
  • Value Objects werden durch ihre Attribute definiert und haben keine Identität.
  • Ein Domain Event repräsentiert ein atomares Ereignis und kann benutzt werden, um fachliche Aktivitäten zu modellieren.
  • Ein Aggregate gruppiert eine Reihe von Entities und Value Objects, um einen konsistenten inneren Zustand zu garantieren.

In einem funktionalen Programmierstil lassen sich Value Objects und Domain Events natürlich auf unveränderliche Objekte abbilden. Ein klassisches Beispiel hierfür ist das Konzept „Kontostand“, das sich aus einem Geldbetrag (z.B. BigDecimal) und einer Währung (z.B. String oder ein enum) zusammensetzt. Man kann dies wie folgt in einer Klasse modellieren:

public class Balance {
  public final BigDecimal amount;
  public final Currency currency;
  public Balance(BigDecimal amount, Currency currency) {
    // ...
  }
}

Es wäre schlicht unsinnig, ohne fachlichen Grund das Ändern der Währung zu ermöglichen. Stattdessen sollte man sich überlegen, die notwendigen domänenspezifischen Operationen möglichst allgemein in der Balance-Klasse bereitzustellen, z.B. die Summierung:

public Balance add(Balance that) {
  if (!this.currency.equals(that.currency))
    throw new IllegalArgumentException();
  return new Balance(this.amount + that.amount, this.currency);
}

Vorbilder für dieses Muster gibt es im JDK zu Hauf, z.B. bietet die java.time-API eine Methode LocalDateTime plus(TemporalAmount) an.

Im Sinne der stark getypten Programmierung könnte man sogar noch weiter gehen und die Währung als Typparameter modellieren, so dass fachlich unsinnige Operationen gar nicht erst kompilieren:

public interface Currency {}

public final class EUR implements Currency {}

public class Balance<C extends Currency> {
  public final BigDecimal amount;
  public final C currency;
  public Balance(BigDecimal amount, C currency) {
    // ...
  }
  public Balance<C> add(Balance<C> that) {
    // ...
  }
  public Balance<EUR> convertToEUR(Rate rate) {
    // ...
  }
}

Kombiniert man diese Technik mit der asynchronen Programmierung, lässt sich im Nu eine Finanzübersicht über mehrere Konten in unserer App implementieren, wobei einerseits die Laufzeitumgebung parallele Anfragen an Banken senden kann, andererseits der Compiler die korrekte Summierung überprüft.

Immutable Entities?

Abhängig von den technischen Rahmenbedingungen ist es durchaus sinnvoll, auch Entitäten und Aggregate als unveränderliche Objekte zu modellieren. Der Vorteil liegt auf der Hand: es wird einfacher, einen konsistenten Gesamtzustand des Aggregats sicherzustellen, wenn sich die enthaltenen Entitäten nicht außerhalb des Aggegrats ändern lassen.

Zur Implementierung kann man sich bei den Konzepten des Event Sourcing bedienen. Die Grundidee besteht darin, die Perspektive bei Änderungen zu wechseln: statt dass Entitäten sich selbst verändern, wendet man Änderungen auf Entitäten an. In Java kann man sich z.B. ein Interface für AccountAction vorstellen, wobei Subklassen für Withdrawal, Deposit usw. angelegt werden. Die Account-Klasse kann diese Aktionen dann abarbeiten. Wichtig ist dabei, dass die Interpretation (ergo die Semantik) der Aktionen klar definiert und deterministisch ist.

Fortschrittliche Systeme implementieren zusätzlich auch zu jeder Aktion eine Gegenaktion, mit der eine Art „Undo/Redo“-Funktionalität implementiert werden kann. In unserer Finanz-App könnten sich so Lastschriften modellieren lassen, die wegen mangelnder Kontodeckung zurückgebucht werden.

Interpreter Pattern

Setzt man das konsequent um, hat man gleich eine ganze Reihe der „Gang of Four“-Patterns benutzt. Trennt man die Interpretation von der Definition der Domänenevents, erhält man das Interpreter Pattern, welches das Standardwerk wie folgt definiert:[2]

Definition einer Repräsentation für die Grammatik einer gegebenen Sprache und Bereitstellung eines Interpreters, der sie nutzt, um in dieser Sprache verfasste Sätze zu interpretieren.

Die Ähnlichkeit zur Ubiquitous Language im Domain-driven Design ist unübersehbar. Übertragen auf unsere Finanz-Domäne könnte man komplexe Finanzprodukte und deren Events (wie z.B. Optionsscheine) als abstrakte Sprache modellieren. Je nach Bounded Context können diese Events dann anders interpretiert werden, beispielsweise aus Steuer- oder Risikosicht.

Testen ja, aber bitte automatisiert

Ein weiterer klarer Vorteil des Interpreter-Entwurfsmusters ist die stark verbesserte Testbarkeit. Statt Datenbank- oder ähnliche Anfragen auch in den Unit-Tests gegen ein reales System fahren zu müssen, kann man sehr leicht Test-Implementierungen bereitstellen. Der Unterschied zum Mocking besteht darin, dass die Schnittstellen klar definiert sind und eine Entkopplung zwischen Tests und Implementierungsdetails eingehalten werden kann.

Zusätzlich kann man sich durch die Verwendung von eigenschaftsgetriebenen Tests jede Menge Zeit und Code einsparen. Die Java-Bibliothek jqwik kann automatisiert Testfälle erzeugen und Eigenschaften abprüfen, die man in gewöhnlichen Unit-Tests händisch schreiben müsste.

@Property
void balance_increase_withdraw(@ForAll Account acc, @ForAll Balance bal) {
  var oldState = acc.getBalance();
  var newState = acc.deposit(bal).withdraw(bal);
  Assert.equals(oldState, newState);
}

Diese Test-Methode generiert 100 verschiedene Eingabefälle einschließlich gern übersehener Grenzfälle wie negative Werte oder Extrema wie MAX_INT. jqwik integriert sich nahtlos in die JUnit-Plattform und kann parallel zu klassischen Unit-Tests existieren.

Separiert man im funktionalen Stil den Zustand von den Events eines Systems, lassen sich mit diesem Ansatz sogenannte modellgetriebene Tests implementieren, die auch komplexe nebenläufige Prozesse simulieren können. Eine hohe Testabdeckung wird dadurch fast automatisch erreicht.

Ein Blick in die Zukunft

Im JEP 360 stehen die Zeichen auf weitere Annäherung an funktionale Sprachen. Es geht dort um sogenannte „sealed classes“, also Klassen (bzw. Interfaces), die nur von einer definierten Menge von anderen Klassen beerbt werden dürfen. Auf dem ersten Blick hat das nichts mit funktionaler Programmierung zu tun. Popularisiert wurde dieses Konzept aber von Scala. So ist eines der Beispiele aus dem JEP-Dokument gleich als Paradebeispiel funktionaler Programmierung zu erkennen:

public sealed interface Expr
    permits ConstantExpr, PlusExpr, TimesExpr, NegExpr {...}

public final class ConstantExpr implements Expr {...}
public final class PlusExpr     implements Expr {...}
public final class TimesExpr    implements Expr {...}
public final class NegExpr      implements Expr {...}

In diesem Beispiel wird ein Interface namens Expr definiert, was anschließend von genau vier (finalen) Klassen implementiert wird.

In Scala ist die Syntax ganz ähnlich, nur dass etwas andere Regeln gelten: die erbenden Klassen brauchen nicht aufgezählt werden; stattdessen müssen sie in der gleichen Source-Datei definiert sein. In Java ist gefordert, dass sich die Klassen alle im gleichen Package bzw. Modul befinden.

Zusammen mit den ebenfalls neuen „switch expressions“ ergeben sich neue Möglichkeiten in der Domänenmodellierung. Denn damit könnte das das umständliche Visitor Pattern endlich der Vergangenheit angehören. Das folgende Beispiel nutzt fiktive Syntax, da die Bausteine derzeit nur als Standardisierungsvorschläge existieren:

public static int evaluate(Expr expr) {
    return switch (expr) {
        case ConstantExpr c -> c.value;
        case PlusExpr p     -> evaluate(p.left) + evaluate(p.right);
        case TimesExpr t    -> evaluate(p.left) * evaluate(p.right);
        case NegExpr n      -> - evaluate(n.operand);
    };
};

Der normalerweise bei einem switch übliche default-Fall kann hier entfallen, denn der Compiler kann mittels der permits-Deklaration in Expr genau erkennen, dass alle möglichen Fälle abgedeckt sind.

Zu guter Letzt stehen mit JEP 359 die Records an, die die Modellierung von unveränderlichen Objekten vereinfachen, da der gängige Boilerplate-Dreisprung aus equals, hashCode und toString entfällt.

  1. standardmäßig auch der Fork–Join–Pool, aber es können auch alternative Executors benutzt werden  ↩

  2. Gamma et al.: „Design Patterns: Entwurfsmuster als Elemente wiederverwendbarer objektorientierter Software“, mitp 2014  ↩

Fazit

Obwohl Java bei weitem nicht so viele komfortable Features wie etwa Scala für die funktionale Programmierung anbietet, ist es sehr wohl möglich, FP-inspirierte Mikro- und Makro-Architekturen umzusetzen. Die Vorteile liegen auf der Hand: bessere Testbarkeit, Performance und Wartbarkeit. Und das beste daran: modelliert man die Fachlichkeit mit DDD, lassen sich viele Konzepte nahtlos in ein funktionales System übertragen.

TAGS

Kommentare

Um die Kommentare zu sehen, bitte unserer Cookie Vereinbarung zustimmen. Mehr lesen