Freie Monaden oder: Wie ich lernte, die Unabhängigkeit zu lieben
Eine enge Kopplung der Beschreibung von Programmteilen und deren Ausführung führt unweigerlich zu Problemen, spätestens beim Testen der Software. Daher wird in diesem Artikel anhand von praktischem Code erklärt, wie uns das Konzept der freien Monade dabei hilft, Beschreibung und Ausführung sauber und elegant voneinander zu trennen.
Voraussetzungen
Alle Code-Beispiele in diesem Artikel sind mit Scala implementiert. Da dieser Artikel praxisorientiert ist, werden die Beispiele unter Zuhilfenahme der Bibliothek Cats umgesetzt. Zwar wird die freie Monade nicht theoretisch erklärt, dennoch sollte das Konzept der Monaden geläufig sein. Eine tolle und einfache Einführung hierzu bietet Functors, Applicatives, And Monads in Pictures. Für den theoretisch interessierten Leser werden an einigen Stellen weiterführende Links bereitgestellt.
Um einen einfachen Einstieg zu ermöglichen, sehen wir uns ein typisches Problem in Verbindung mit Seiteneffekten an.
So nicht!
Der folgende Scala-Code zeigt eine einfache Implementierung eines Adressbuchs. Hierzu definieren wir eine case-Klasse, die eine Adresse repräsentiert und ein Objekt, das Funktion für die Persistierung selbiger anbietet:
Die einfach Verwendung des Repos direkt aus der Businesslogik heraus, führt unmittelbar zu mehreren Problemen. Was stimmt damit nicht?
- Das Program ist schwer testbar: Zwar sind Tests in integrativen Umgebungen möglich, jedoch ist das Ausführen von einfachen Unit-Tests nur schwer realisierbar. Das Ausklammern des Datenbank-Backend ist nicht vorgesehen und somit muss dieses stets präsent sein, wenn Tests ausgeführt werden. Je nach Code-Struktur kann das Testen der eigentlich Businesslogik daher nur mit viel Aufwand stattfinden.
- Seiteneffekt werden sofort ausgeführt: Ein Grundgedanke der funktionalen Programmierung ist die Erstellung von referentiell transparenten Programmen, also ohne Seiteneffekte. Diese Seiteneffekt sollen möglichst erst „am Ende des Universum“ ausgeführt werden. Im Beispiel würden Effekte jedoch verstreut an vielen Stellen im Programm ausgeführt werden. Insbesondere würde die Funktion
removeBielefeld
bei Mehrfachausführung unterschiedliche Ergebnisse liefern. - Die Schnittstelle ist zu speziell: Die Trennung von Beschreibung und Ausführung ist ein wichtiges Konzept, das zu modularem, exzellent wartbarem und testbarem Code führt. Bei der Beschreibung unserer Adressbuch-Operationen sollten wir uns nicht mit Futures, Eithers und Throwables abmühen müssen. Mehr noch, setzen wir damit ein bestimmtes Datenbank voraus (in diesem Fall ein asynchrones, das Exceptions verwendet), das damit zu stark in der Business-Logik unserer Anwendung verzahnt und somit nur schwer austauschbar wäre.
Ein erster Schritt, um derartige Aspekte zu entkoppeln, ist die Formulierung der Operationen als eigene Entitäten. Damit ist es uns möglich, die nötigen Operationen als Daten zu repräsentieren, die dann erst später ausgeführt werden.
Unsere kleine Sprache
Um die Operationen als Entitäten zu formulieren, erstellen wir für jede Operation eine Datenrepräsentation. Diese Repräsentation fassen wir als algebraischen Summentyp zusammen:
Der Typ-Parameter A
des traits AdressBookOp[A]
bestimmt dabei den Rückgabetyp unserer Operationen. Zwar lassen sich mit diesen Operationen einfache Programme beschreiben, etwa durch Listen von Befehlen, es lassen sich allerdings keine komplexe Zusammenhänge formulieren. Es ist nicht möglich gelesene Daten zu referenzieren ohne einen Seiteneffekt auszuführen, wie zum Beispiel in der Funktion removeBielefeld
. Gerne hätten wir hier eine Möglichkeit, um Rückgabewerte (ohne Ausführung des Effekts) binden zu können.
Freie Monade, zur Rettung!
Wir verwenden in diesem Artikel die freie Monade aus Cats. Cats ist eine Scala-Bibliothek, die mächtige Abstraktionen aus der Funktionalen Programmierung bereitstellt. Wir werden neben der Funktionalität zu freien Monaden weitere Abstraktionen, wie die Natürliche Transformationen aus dieser Bibliothek verwenden. Die freie Monade wird uns helfen, die Einschränkungen unserer kleinen Sprache zu überwinden. Eine systematische Hinleitung zur internen Funktionalität von freien Monaden kann in diesem Artikel nachgelesen werden.
Um unsere Befehle mit der freien Monade aus Cats verwenden zu können, müssen wir diese in die Monade heben. Dazu erstellen wir für jeden Befehl einen sogenannten Smart-Konstruktor:
Die Smart-Konstruktoren nehmen jeweils die Parameter der AddressBookOps
entgegen, erzeugen die Operation und verwenden liftF
, um die Operation in die freie Monade zu heben. Zurück kommt ein Wert vom Typ AddressBookOpF[A]
(Alias für Free[AddressBookOp, A]
), wobei A
der erwartete Rückgabewert der Operation ist. Die Definition dieser Smart-Konstruktoren macht die Beschreibung der späteren Programme einfacher. Die Funktion removeBielefeld
ist mithilfe der Definition einfach monadisch beschreibbar. Insbesondere lassen sich Zwischenergebnisse binden:
Durch die Bindung der Zwischenwerte lassen sich komplexe Programme formulieren, ohne über Details der Ausführung sprechen zu müssen: Bei der Formulierung des Programmes wird weder über Throwables noch über Futures nachgedacht. traverse
ist eine weitere kleine Hilfestellung aus der Cats-Bibliothek, für die wir die zusätzlichen Cats-Imports benötigen. Das Ablaufen der Zwischenergebnisliste liese sich auch durch fold
Beschreiben.
Unsere neue Version von removeBielefeld
gibt ein Wert vom Typ AddressBookOpF[List[Address]]
zurück. Diese Programmbeschreibung ist kombinier- und damit wiederverwendbar:
Die Ausführung
Wir haben nun also eine mächtige Repräsentation, um Operationen zu repräsentieren, Zwischenergebnisse zu referenzieren und Abstraktionen zu kombinieren. Was fehlt, ist die Ausführung. Dazu benötigen wir einen Interpreter, der AddressBookOpF[List[Address]]
entgegen nimmt und interpetiert (oder kompiliert). Dazu definieren wie eine Natürliche Transformation. Salopp gesagt überführen wir unsere Programme aus der freien Monade in eine Zielmonade. Im folgenden Beispiel wird die State-Monade als einfacher Adressspeicher verwendet:
In Cats wird die Natürliche Transformation von A
nach B
durch den Typ A ~> B
beschrieben (eine Funktor-Transformation vom Typ FunktionK
). In unserem Fall überführen wir AddressBookOps
nach AddressState
. Der Interpreter lässt sich durch die Verwendung von foldMap
auf ein Programm vom Typ AddressBookOpF
anwenden:
Ein Interpreter für unser ursprüngliches Adressbuch-Repo könnte dann wie folgt aussehen:
Wie zu sehen ist, werden wir erst bei der Intepretation unseres Programmes mit den technischen Aspekten wie Futures und Throwables konfrontiert. Es existiert eine klare Trennung zwischen Beschreibung und Ausführung. Diese geht so weit, dass wir während der Formulierung der Logik nicht wissen müssen, wer unsere Beschreibung in welcher Form ausführt. Bisher haben wir zwar von Datenbanken oder Repos gesprochen. Doch nichts hindert uns daran, einen aktorbasierten Service als Adressbuch zu implementieren. Die entstehenden Beschreibungen können problemlos in Form von Nachrichten zwischen Aktoren ausgetauscht werden.
In dieser Unabhängigkeit liegt der Charme freier Monaden.
Worte zur Testbarkeit
Ein großer Vorteil der Trennung von Beschreibung und Ausführung ist die separate Testbarkeit der Beschreibungen. So können Beschreibungen auch außerhalb von Integrationstests getestet werden ohne Datenbanken bereitstellen zu müssen. Das hört sich zuerst charmant an, ist allerdings bei freien Monaden schwieriger, als zunächst angenommen. Beschreibungen repräsentieren zwar abstrakte Syntax, diese liegen jedoch in Form einer Funktion vor und lassen sich zunächst nicht einfach inspizieren. Zwar können wir Test-Interpreter, wie den State-Interpreter aus dem obigen Beispiel, implementieren. Allerdings besteht hier die Gefahr, dass sich der Test- und der „echte“ Interpreter abweichend verhalten.
Denkbar wäre jedoch die Implementierung eines sehr einfachen Testinterpreters, der die ausgeführten Anweisungen beispielsweise lediglich aufzeichnet. Allerdings muss hierzu in einigen Fällen trotzdem das Verhalten implementiert werden, insbesondere dann, wenn bestimmte Operationen nur bedingt durch gebundene Daten ausgeführt werden. Wollen wir die enstehenden Programme also inhaltlich testen, müssen wir ein Trade-Off zwischen Genauigkeit und Fehleranfälligkeit der Interpretation eingehen.
Wird konsequent zwischen Beschreibung und Ausführung getrennt, können dennoch große Codeteile in Unit-Tests abgedeckt werden, ohne eine integrierte Umgebung bieten zu müssen. Diese Entkopplung macht das Testen unserer Businesslogik oft schon um vieles einfacher. Eine mögliche Synergie, die dies verdeutlicht, ist in Kombination mit dem aktorbasierten Observer-Pattern. Dabei helfen uns Nachrichten in Form unserer Beschreibungen anhand der freien Monade bei einer sauberer Trennung zwischen Services und trotzdem ist der Austausch flexibler und komplexer Anweisungen möglich. Alle Einheiten sind separat testbar und die Businesslogik kann außerhalb der Integration getestet werden.
Resumé
In diesem Blog-Artikel haben wir entlang eines einfachen Beispiels eine kleine Sprache mit Hilfe der freien Monade implementiert. Diese Sprache hat es uns ermöglicht, kombinierbare Beschreibungen ohne Wissen über die spätere Ausführung zu auszudrücken. Erst durch Intepreter (oder Compiler) werden die Beschreibungen ausgeführt und Details über die Ausführung bekannt.
Der in diesem Artikel verwendete Code ist auf Github verfügbar.
Im nächsten Post sehen wir, wie man die freie Monade von Grund auf in Scala implementiert.