Monaden werden in Kotlin erstklassig unterstützt. Dieser Artikel zeigt, wie und beispielhaft wofür man sie nutzen kann – für Dependency Injection und die Modellierung von Abläufen, beides wichtige Aspekte moderner Architektur. Die Schlüsselrolle kommt dabei suspend-Funktionen zu.
Kontext – implizit, aber richtig
In einem Softwareprojekt ist es sinnvoll, Domänenlogik von technischen Aspekten zu trennen und diese technischen Aspekte möglichst nicht zu fest zu verdrahten, damit sie austauschbar sind. Ein Problem ist dabei der Zugriff auf Kontext, also die Außenwelt des Projekts. Der Artikel "Dependency Injection mit funktionaler Programmierung" im JavaSPEKTRUM 01/2022 [Spe22] zeigt, wie man in Java dafür Monaden verwenden kann. Allerdings zeigt der Artikel auch eindrücklich, warum Monaden in Java nicht besonders populär sind: Ein monadisches Programm benötigt viele Klammern, das Beispiel aus dem Artikel sieht so aus:
public JdbcComputation<Void> computation() {
return
Jdbc.execute("DROP TABLE customers IF EXISTS").andThen(
...
}));}));
}
In Kotlin geht das glücklicherweise besser. Doch zunächst noch einmal zur Problemstellung: Wenn eine Funktion auf eine Datenbank zugreifen will, benötigt sie ein Objekt, das die Verbindung zu der Datenbank hält – in Spring vom Typ JdbcTemplate
. In typischen DI-Frameworks deklariert das Programm mit einer Annotation wie @Autowired
(Spring), dass sich das Framework um die Bereitstellung des Objekts kümmern möge. Dadurch entsteht aber Kopplung ans Framework und temporale Kopplung an die Initialisierungsreihenfolge. (Details finden sich im Artikel [Spe22], dessen Lektüre aber nicht Voraussetzung für diesen Artikel ist.)
Zunächst steht die Lösung des Problems mit der temporalen Kopplung an. Dazu könnte das Programm das JdbcTemplate
-Objekt als Argument an alle Funktionen übergeben, welche auf die Datenbank zugreifen. Also so etwa ab jetzt in Kotlin:
fun storeUser(User user, JdbcTemplate jdcb) { ... }
Falls storeUser
noch Unterfunktionen hat, muss sie das jdbc
-Argument an diese weitergeben. Auf die Art und Weise wird sichergestellt, dass diese Funktion nur aufgerufen werden kann, wenn ein JdbcTemplate
-Objekt erstellt und (hoffentlich) dabei initialisiert wurde. Die Abhängigkeit von diesem Objekt gegenüber der impliziten @Autowired
-Lösung ist explizit geworden – das ist in der Softwarearchitektur oft der erste Schritt zur Besserung.
Allerdings ist es natürlich umständlich, jede einzelne Funktion mit so einem Parameter zu versehen und den durch jede Unterfunktion durchzufädeln. Außerdem ist die Abhängigkeit zu Spring durch den JdbcTemplate
-Typ nach wie vor vorhanden. Beide Probleme wollen wir lösen.
Um das Ergebnis gleich vorwegzunehmen, das Programm aus dem Spring-Tutorial soll am Ende wie in Listing 1 aussehen. Keine exzessiven Klammern, außerdem keine Erwähnung von JdbcTemplate
mehr. Wichtig ist das jdbc { ... }
um das Programm herum, das die Funktionen execute
, batchUpdate
und query
verfügbar macht, sowie die Funktion pure, die ähnlich zu return den finalen Rückgabewert des Programms bestimmt.
jdbc {
execute("DROP TABLE customers IF EXISTS")
execute(
"CREATE TABLE customers(" +
"id SERIAL, first_name VARCHAR(255), last_name VARCHAR(255))"
)
val splitUpNames = listOf("John Woo", "Jeff Dean",
"Josh Bloch", "Josh Long").stream()
.map { name -> name.split(" ") }
.collect(Collectors.toList())
.map { it.toTypedArray<Any>() }
batchUpdate(
"INSERT INTO customers(first_name, last_name) VALUES (?,?)",
splitUpNames.toList())
val customers = query(
"SELECT id, first_name, " +
"last_name FROM customers WHERE first_name = ?",
arrayOf("Josh"))
{ rs, rowNum -> Customer(rs.getLong("id"),
rs.getString("first_name"),
rs.getString("last_name")) }
customers.forEach { customer -> log.info(customer.toString()) }
pure(Unit)
}
Damit das alles funktioniert, müssen zwei Elemente von Kotlin ineinandergreifen:
- sogenannte suspend-Funktionen, um eine Reader-Monade zu realisieren, die Zugriff auf JdbcTemplate ermöglicht,
- die sogenannten function literals with receiver, die es erlauben, in die geschweiften Klammern die SQL-Funktionen zu importieren.
Eine Reader-Monade in Kotlin
Das Programm aus Listing 1 benutzt die Funktion execute. Sie ist folgendermaßen definiert:
suspend fun execute(sql: String): Unit =
ask().execute(sql)
Die Funktion ask()
liefert das JdbcTemplate
-Objekt ab, das für die Ausführung der SQL-Operation benötigt wird. Wo kommt es her? Es soll aus dem „Kontext” kommen, aber nicht aus einem DI-Mechanismus mit den oben beschriebenen Problemen.
Das Programm aus Listing 1 benutzt für den Zugriff auf den Wert aus dem Kontext eine sogenannte Reader-Monade. In einer Reader-Monade wird ein Programm, das auf den Kontext zugreifen möchte, als Objekt des Typs Reader<R, A>
dargestellt. Das Programm liefert ein Ergebnis vom Typ A
und kann auf ein Objekt aus dem Kontext des Typs R
zugreifen. Listing 2 zeigt die Definition von Reader
.
sealed interface Reader<R, out A> {
fun <B> bind(next: (A) -> Reader<R, B>): Reader<R, B>
data class Ask<R, out A>(
val cont: (R) -> Reader<R, A>): Reader<R, A> {
override fun <B> bind(next: (A) -> Reader<R, B>): Reader<R, B>
= Ask { r -> cont(r).bind(next) }
}
data class Pure<R, out A>(val result : A): Reader<R, A> {
override fun <B> bind(next: (A) -> Reader<R, B>): Reader<R, B>
= next(result)
}
}
Den Kontext-Zugriff erledigt die Klasse Ask
, die als Attribut eine sogenannte Continuation bekommt, effektiv ein Callback, der aufgerufen wird mit dem Kontext-Objekt. Die Continuation liefert das Programm zurück, mit dem es weitergeht. Die Pure
-Klasse ist dafür zuständig, ein Programm mit einem Ergebnis zu beenden. Damit ist es schon einmal möglich, ein Reader-Programm so zu formulieren:
val readerProgram = Ask<Int, String> { r ->
Pure((r + 1).toString())
}
Das entspricht etwa dem, was in Java möglich ist, und ist noch ziemlich umständlich – muss also noch besser werden.
Zunächst noch kurz zur bind
-Methode (in den Java-Klassen Stream
und Optional
auch unter dem Namen flatMap
bekannt): Sie ist die Standard-Operation der Monade, um zwei Reader-Programme hintereinanderzuschalten.
So ein Objekt vom Typ Reader<R, A>
ist aber nur eine Beschreibung eines Programms, das auf den Kontext zugreift. Reader
ist eine sogenannte freie Monade und repräsentiert eine fundamental objektorientierte Idee: Alles, also auch Abläufe, wird durch Objekte repräsentiert. Damit so ein Reader
-Programm läuft, muss es explizit ausgeführt werden – und dabei geschieht auch die eigentliche Dependency Injection, die festlegt, welches Objekt aus Ask
zurückgegeben wird. Dies erledigt die folgende einfache Funktion run
, die für jede Ask
-Operation deren Continuation mit dem Objekt r aufruft, das an run
übergeben wird:
tailrec fun <R, A> run(reader: Reader<R, A>, r: R): A =
when (reader) {
is Ask -> run(reader.cont(r), r)
is Pure -> reader.result
}
Zum Beispiel liefert run(readerProgram, 7)
den Text "8"
. Die Dependency Injection passiert also beim Aufruf von run
explizit, was die implizite temporale Kopplung vermeidet, die das Programm aus dem Spring-Tutorial hatte. Das ist architektonisch gut, aber von der Notation her noch schlecht.
Eine DSL für Reader-Programme
Das winzige Beispiel von oben soll besser so aussehen:
val readerProgram = reader<Int, String> {
val r = ask()
pure((r + 1).toString())
}
Mit anderen Worten: wie ein ganz normales Kotlin-Programm, nur eben mit reader<..., ...> { ... }
drumherum. Trotzdem soll es ein Reader
-Objekt erzeugen. Wie ist das möglich? Hier ist die Definition von Reader
und der Klasse ReaderDsl
, welche ask
und pure
bereitstellt:
fun <R, A> reader(block: suspend ReaderDsl<R>.() -> A): Reader<R, A>
= MonadDSL.effect(ReaderDsl(), block)
open class ReaderDsl<R> {
suspend fun ask(): R =
Reader.Ask<R, R> { Reader.Pure(it) }.susp()
suspend fun <A> pure(result: A): A =
MonadDSL.pure<Reader<R, A>, A>(result) { Reader.Pure(it)
}
Diese Funktionen bedienen sich der MonadDSL
-Klasse, die bei der Definition solcher monadischen DSLs hilft – dazu gleich mehr. Außerdem ist noch eine Methode susp
notwendig, mit dieser Definition:
sealed interface Reader<R, out A> {
...
suspend fun susp(): A =
MonadDSL.susp<Reader<R, A>, A>(this::bind)
...
}
Auf Grundlage der ReaderDsl
-Klasse kann nun eine DSL-Klasse für Datenbankprogramme gebaut werden, mit der das Spring-Tutorial-Programm funktioniert. Die SQL-Funktionen rufen allesamt ask()
auf, um an das JdbcTemplate
-Objekt zu kommen (s. Listing 3).
typealias JdbcComputation<A> = Reader<JdbcTemplate, A>
class JdbcDsl : ReaderDsl<JdbcTemplate>() {
suspend fun execute(sql: String): Unit =
ask().execute(sql)
suspend fun batchUpdate(sql: String, batchArgs: List<Array<Any>>)
: Array<Int> = ask().batchUpdate(sql, batchArgs)
suspend fun <T> query(sql: String, args: Array<Any>,
rowMapper: (Row, Int) -> T)
: List<T> = ask().query(sql, args, rowMapper)
}
val Jdbc = JdbcDsl()
fun <A> jdbc(block: suspend JdbcDsl.() -> A): JdbcComputation<A>
= Reader.reader { Jdbc.block() }
Coroutinen, Continuations und Monaden
Der Sourcecode für MonadDSL
kann im Repositorium [REP] eingesehen werden. Die genaue Definition würde den Rahmen dieses Artikels sprengen. Dieser Abschnitt erläutert die grundsätzliche Funktionsweise für Interessierte.
Der Schlüssel ist das Wort suspend
an den Funktionen in JdbcDsl
. Es verwandelt eine Funktion in eine sogenannte Coroutine und versetzt den Compiler in einen anderen Modus, der auf der Funktion daraufhin eine sogenannte CPS-Transformation durchführt. „CPS” steht für Continuation-Passing Style und ist eine bestimmte Art, Funktionen zu schreiben. Normalerweise sind Programme „verschachtelt”, indem das Ergebnis eines Funktionsaufrufs zurückgegeben wird:
f(g(h(x)))
Bei CPS geht es niemals zurück: Wenn eine Funktion fertig ist, gibt sie kein Ergebnis „zurück”, sondern ruft stattdessen eine an sie übergebene Funktion auf, die weitermacht – eben die Continuation, englisch für „Fortsetzung”. In CPS sieht der obige geschachtelte Funktionsaufruf so aus:
h(x) { g(it) { gr -> f(it) { ... } } }
Das Programm wird also linearisiert – die Funktionsaufrufe stehen in der Reihenfolge, in der sie auch zur Laufzeit passieren. Außerdem bekommt jedes Zwischenergebnis einen Namen und jede Continuation ist ein Objekt, das gespeichert und benutzt werden kann, um eine Berechnung zu reaktivieren.
Kotlin bietet für suspend
-Funktionen eine Methode suspendCoroutine
, die es erlaubt, ein Programm bis zur nächsten Continuation laufen zu lassen und dann anzuhalten. MonadDSL
benutzt suspendCoroutine
, um bei jeder Continuation einen Aufruf von bind
einzuschmuggeln und so aus einer „ganz normalen” suspend
-Funktion ein monadisches Programm zu machen.
Noch weniger Kopplung
Die JdbcDsl
-Monade hat noch ein Problem: Zwar enthält das Spring-Beispiel keine explizite Erwähnung mehr von JdbcTemplate
, aber JdbcDsll
ist eine Unterklasse von ReaderDsl<JdbcTemplate>
. Es gibt also immer noch unerwünschte Kopplung ans Framework.
In realen Projekten sollte man sich für die Beschreibung von Abläufen deshalb auch nicht an "Technik" orientieren wie der Reader-Monade oder JDBC, sondern an den Operationen der Domäne. Listing 4 zeigt das Beispiel einer "Shopping"-Monade eines fiktiven eCommerce-Projekts mit Operationen zum Abholen eines Artikels und eines Kunden-Datensatzes aus der externen Datenbank.
sealed interface Shopping<out A> {
fun <B> bind(next: (A) -> Shopping<B>): Shopping<B>
data class GetArticle<out A>(val id: Int, val cont: (Article)
-> Shopping<A>)
: Shopping<A> {
override fun <B> bind(next: (A) -> Shopping<B>): Shopping<B>
= GetArticle(id) { article -> cont(article).bind(next) }
}
data class GetCustomer<out A>(val id: Int, val cont: (Customer)
-> Shopping<A>)
: Shopping<A> {
override fun <B> bind(next: (A) -> Shopping<B>): Shopping<B>
= GetCustomer(id) { customer -> cont(customer).bind(next) }
}
data class Pure<out A>(val result: A): Shopping<A> {
override fun <B> bind(next: (A) -> Shopping<B>):
Shopping<B> = next(result)
}
}
Diese Monade kommt ganz ohne „Technik” aus und kann in Domänencode verwendet werden. Die DSL dafür wird genauso gebaut wie auch bei der Reader-Monade. Damit können sequenzielle Abläufe abgebildet werden, die mit Artikeln und Kunden zusammenhängen. Häufig enthalten aber solche Abläufe auch Nebenläufigkeit. Ein nebenläufiger Prozess wird abgebildet durch eine Klasse Future<A>
, wobei A
das Ergebnis des Prozesses ist, wenn er fertig ist. Das einzige Attribut von Future
ist eine Funktion, die dieses Ergebnis liefert:
data class Future<out A>(val thunk: () -> Any)
Das Any
ist leider notwendig, weil das Kotlin-Typsystem den Zusammenhang zwischen der Monade und Future
nicht typsicher abbilden kann. Future
wird von zwei neuen Operationen in der Shopping
-Monade benutzt: Fork
, um einen nebenläufigen Prozess zu starten, und Join
, um dessen Ergebnis (später) abzurufen (Listing 5). Mit den entsprechenden Operationen in der DSL dazu sieht so ein Beispielprogramm wie in Listing 6 aus.
sealed interface Shopping<out A> {
data class Fork<R, out A>(val computation: Shopping<R>,
val cont: (Future<R>) -> Shopping<A>)
: Shopping<A> {
override fun <B> bind(next: (A) -> Shopping<B>): Shopping<B>
= Fork(computation) { forked -> cont(forked).bind(next) }
}
data class Join<R, out A>(val future: Future<R>, val cont: (Any)
-> Shopping<A>)
: Shopping<A> {
override fun <B> bind(next: (A) -> Shopping<B>): Shopping<B>
= Join(future) { result -> cont(result).bind(next) }
}
}
shopping {
val customerF = fork(shopping { pure(getCustomer(1)) } )
val articleF = fork(shopping { pure(getArticle(1)) } )
val customer = join(customerF)
val article = join(articleF)
pure(customer.firstName + article.name)
}
Die beiden Aufrufe von fork
drücken aus, dass getCustomer
und getArticle
beide nebenläufig im Hintergrund ablaufen können, insbesondere also gleichzeitig. Dies könnte sinnvoll sein, wenn beide Datensätze aus unterschiedlichen Datenbanken kommen. Genauso sinnvoll könnte aber auch sein, beide Aufrufe erst aufzusammeln und dann gemeinsam an dieselbe Datenbank schicken zu lassen. Die Monade lässt Raum für beides. Erst die join
-Aufrufe warten dann auf das Ergebnis des jeweiligen Prozesses.
Die Funktion, die dann einen Shopping
-Ablauf ausführt, hat also große Freiheiten nicht nur bei der Implementierung der Domänenoperationen, sondern auch bei der Implementierung der Nebenläufigkeit beziehungsweise der Auswahl des geeigneten Frameworks dafür. Nachträglich so etwas wie Profiling oder Logging zu implementieren, kann ebenfalls in dieser Funktion stattfinden. Domäne und Technik sind also wahrhaft voneinander entkoppelt.
Fazit
Monaden erlauben die Definition von Abläufen in reiner Domänenlogik, ohne Bezug zur Technik darunter, und dienen damit der architektonischen Entkopplung. Sie vermeiden die Probleme typischer DI-Frameworks, die zu Kopplung an Framework und Abfolge neigen.
Monaden können noch viel mehr – zum Beispiel Domänenmodellierung, Exceptions oder probabilistische Programmierung. Damit ihre Benutzung aber praktikabel wird, ist Unterstützung von der Programmiersprache erforderlich. Echte funktionale Sprachen wie Scala oder Haskell bieten da viel Komfort durch spezielle Syntax und mächtige Überladungsmechanismen. In Kotlin macht es die Kombination aus DSL-Funktionalität und suspend
-Funktionen zusammen mit der daran hängenden CPS-Transformation. Viel Spaß beim Ausprobieren!
Weitere Informationen
[Rep] Repositorium mit MonadDSL
und den Code-Beispielen dieses Artikels,
https://github.com/active-group/kotlin-free-monad
[Spe22] M. Sperber, Dependency Injection mit funktionaler Programmierung, in: JavaSPEKTRUM, 01/2022
[Spr] Accessing Relational Data using JDBC with Spring,
https://spring.io/guides/gs/relational-data-access/