Das Wissensportal für IT-Professionals. Entdecke die Tiefe und Breite unseres IT-Contents in exklusiven Themenchannels und Magazinmarken.

SIGS DATACOM GmbH

Lindlaustraße 2c, 53842 Troisdorf

Tel: +49 (0)2241/2341-100

kundenservice@sigs-datacom.de

Funktionale Programmierung für bessere Architektur

Monaden sind in der funktionalen Programmierung so eine Art Allzweckwaffe für alle möglichen Probleme, die scheinbar nichts miteinander zu tun haben: asynchrone Programmierung, Nebenläufigkeit, Parallelität, Exceptions, Modellierung von Prozessen, Dependency Injection (DI) und architektonische Entkopplung. In funktionalen Sprachen wie Haskell, Scala oder F# sind Monaden fester Bestandteil der Programmiersprache mit eigener Syntax, und deshalb dort Alltag in der Programmierung. In Java fristen sie ein Schattendasein, weil diese Unterstützung fehlt. In Kotlin sieht das anders aus.
Author Image
Michael Sperber

Geschäftsführer


  • 25.09.2023
  • Lesezeit: 13 Minuten
  • 128 Views

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)
}
Listing 1: Das Programm aus dem Spring-Tutorial

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)
}
}
Listing 2: die Definition von Reader

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() }
Listing 3: Die SQL-Funktionen rufen ask() auf, um an das JdbcTemplate-Objekt zu kommen

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)
}
}
Listing 4: "Shopping"-Monade

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) }
}
}
Listing 5: Fork und Join
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)
}
Listing 6: Beispielprogramm

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/

. . .

Author Image

Michael Sperber

Geschäftsführer
Zu Inhalten

Dr. Michael Sperber ist Geschäftsführer der Active Group GmbH. Er ist international anerkannter Experte für funktionale Programmierung und wendet sie seit über 20 Jahren in Forschung, Lehre und industrieller Entwicklung an.

Author Image
Zu Inhalten
Benedikt Stemmildt ist leidenschaftlicher Softwarearchitekt, Full-Stack-Entwickler und Speaker mit Begeisterung für Technologie, Architektur und Organisation. Er entwickelt und betreibt Software, datengetrieben mit Fokus auf Kundenmehrwert, bildet sich und andere gern aus und weiter. Benedikt Stemmildt ist stolzes Gründungsmitglied der Hacker-School.

Artikel teilen