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 CompletableFuture
s zu erzeugen und bestehende miteinander zu verknüpfen:
In diesem Code-Schnipsel wird eine Operation in drei Schritten ausgeführt:
- Abruf einer REST-Schnittstelle mittels des asynchronen HTTP-Clients (seit Java 11)
- Ausführung eines komplexen Algorithmus auf dem abgerufenen Wert
- 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:
-
CompletableFuture
sind ganz gewöhnliche Objekte, die umhergereicht werden können - 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:
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:
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:
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.
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:
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:
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.