Rein Funktionale Programmierung mit Kotlin und Arrow
- von Nicolai Mainiero
Nicolai Mainiero, Softwarearchitekt bei sidion
Funktionale Programmierung wird zunehmend beliebter, da sie hilft, komplexe Anforderungen in kleine, einfach zu verstehende Funktionen zu zerlegen, um diese dann zu verbinden und komplexe Ergebnisse zu erzielen. Darüber hinaus bietet die funktionale Programmierung ganz neue Möglichkeiten, abseits der bekannten Design Patterns, den Quellcode zu strukturieren, ohne dass dabei bekannte Methoden wie Interfaces, Inversion of Control oder die Modularität aufgegeben werden. Arrow ist der funktionale Begleiter zu Kotlins Standardbibliothek.
Einführung in funktionale Programmierung
Zunächst eine kurze Einführung, was funktionale Programmierung meiner Meinung nach ausmacht. Bei der funktionalen Programmierung werden Programme durch Definition und Verknüpfung einzelner Funktionen konstruiert. Diese Funktionen haben keine Seiteneffekte, das bedeutet, dass es keine von außen sichtbaren Veränderungen des Zustands gibt. Funktionen werden wie alle anderen Datenstrukturen behandelt, sind also sogenannte First-Class Citizen. Das ermöglicht die Definition von sogenannten Funktionen höherer Ordnung, also Funktionen, die neue Funktionen erzeugen. Eine weitere Eigenschaft einiger funktionaler Programmiersprachen ist die sogenannte referenzielle Transparenz, die besagt, dass der Wert eines Ausdrucks nur von seiner Umgebung, aber nicht vom Zeitpunkt seiner Auswertung abhängig ist. Es existieren verschiedene Sprachen, wie Scala oder Clojure, die auf der JVM ausgeführt werden und die von sich aus behaupten funktional zu sein. Kotlin beansprucht diese Eigenschaft für sich nicht direkt. Warum also mit Kotlin programmieren, wenn man funktionalen Code schreiben will? Im Gegensatz zu Clojure ist die Syntax einem Java-Entwickler deutlich vertrauter, außerdem sind eine Reihe an Sprachkonstrukten vorhanden, die funktionale Programmierung möglich und auch ergonomisch machen. Nehmen wir folgendes Codebeispiel in Java, Kotlin und Clojure.
UnaryOperator<Double> squared = x -> x * x;
BinaryOperator<Double> ringArea = (outerRadius, innerRadius) -> Math.PI *
(squared.apply(outerRadius) - squared.apply(innerRadius));
Java
val squared: (Double) -> Double = {x -> x * x}
val ringArea = {outerRadius: Double, innerRadius: Double -> Math.PI *
(squared(outerRadius) - squared(innerRadius))}
Kotlin
(defn squared [x] (* x x))
(defn ring-area [outer-radius inner-radius]
(* Math/PI
(- (squared outer-radius)
(squared inner-radius))))
Clojure
In Java ist es relativ umständlich, Funktionen zu definieren und sie an Variablen zur weiteren Verwendung zu binden. Das var Schlüsselwort, das ab Java 10 verfügbar ist, hilft uns hier auch nicht weiter, da der Lambda-Ausdruck noch keinen Typ hat, der durch Typinferenz bestimmt werden kann. Das bedeutet, dass in Java immer der Typ angefügt werden muss, und dieser auch noch unterschiedlich je nach Anzahl der Parameter ist. Es existieren auch nur Typen für ein oder zwei Parameter. Sollen Funktionen mit mehr als zwei Parametern definiert werden, muss auf Currying ausgewichen werden. In Clojure hingegen muss man sich an eine ganz andere Syntax gewöhnen, die mit Präfixnotation arbeitet. Das bedeutet, der Operator wird als erstes genannt und dann erst die Operanden. Kotlin ist hier eine passende Alternative. Die Unterstützung von Funktionen als First-Class Citizen und der zu Java vertrauten Syntax bieten eine gute Kombination um funktional zu programmieren.
Arrow
Womit hilft Arrow nun bei der funktionalen Programmierung? Die Bibliothek besteht aktuell aus vier Komponenten mit verschiedenen Aufgaben. Arrow Core enthält Datentypen, wie zum Beispiel Option oder Either, und Typklassen, wie Eq aber auch Applicative. Effects stellt uns Werkzeuge zur Verfügung, Seiteneffekte handhabbar zu machen. Dazu gehört unter anderem der IO Datentyp. Optics enthält Funktionen, um unveränderliche Datenstrukturen zu manipulieren. Die bekannteste Funktion ist vermutlich Lenses, aber auch Prism und einfache Getter und Setter sind darin zu finden. Zuletzt Meta, mit der man Compiler Plugins schreiben kann.
Algebraische Datentypen
Die Datentypen abstrahieren über ein Programmierpattern und sind so generalisiert, dass sie vielfältig verwendet werden können. Zum Beispiel Option<A>
beschreibt, dass es einen Wert geben kann oder nicht, beziehungsweise er nicht erzeugt werden konnte. Dieser Typ ist ein sogenannter algebraischer Datentyp. Das sind zusammengesetzte Datentypen, die man weiter in Summentypen und Produkttypen unterteilt. Produkttypen sind zum Beispiel Tuple oder Records. Produkttypen enthalten also typischerweise mehrere Felder. Bei Summentypen, wie zum Beispiel Option
existiert genau eine der beiden möglichen Ausprägungen. Wenn eine Variable vom Typ Option
ist, dann ist entweder ein Wert vorhanden (Some()
) oder nicht (None
). In Kotlin wird diese mit Hilfe einer sogennannter Sealed Class definiert. Dadurch können keine weiteren Ausprägungen hinzugefügt werden und dadurch bestehenden Code ungültig machen. Das Companion Objekt enthält die notwendigen Konstruktoren für die einzelnen Ausprägungen. Algebraische Datentypen können mit Hilfe von Patternmatching wieder zerlegt werden. Der Compiler hilft dabei, dass kein Fall vergessen wird. Algebraische Datentypen wurden mit der Sprache Hope etwa 1970 an der Universität von Edinburgh eingeführt.
sealed class Option<out A> {
companion object {
fun <A> just(a: A): Option<A> = Some(a)
fun <A> empty(): Option<A> = None
}
object None : Option<Nothing>() {
}
data class Some<out T>(val t: T) : Option<T>() {
}
}
Neben Option
gibt es noch weitere Datentypen, die den funktionalen Stil unterstützen. Zum Beispiel Either
das verwendet werden kann, um Funktionen abzubilden, die fehlschlagen können. Anstatt dass diese Funktionen eine Exception werfen, kann mit Hilfe von Either
der Erfolgs- oder der Fehlerfall abgedeckt werden. Das ermöglicht es, Funktionen zu verketten, ohne nach jedem Aufruf auf Fehler prüfen zu müssen.
Anstatt folgendem Code, der eine Exception wirft
fun parse(s: String): Int =
if (s.matches(Regex("-?[0-9]+"))) s.toInt()
else throw NumberFormatException("$s is not a valid integer.")
schreiben wir folgende Funktion, die bereits im Rückgabewert klar kommuniziert, dass es hier zu einem Fehler kommen kann.
fun parse(s: String): Either<NumberFormatException, Int> =
if (s.matches(Regex("-?[0-9]+"))) Either.Right(s.toInt())
else Either.Left(NumberFormatException("$s is not a valid integer."))
Typklassen
Ein weiterer Baustein sind die sogenannten Typklassen. Hiermit wird gleichartiges Verhalten außerhalb des Typs implementiert und zur Verfügung gestellt. Damit ist es wie in Java über Interfaces möglich, Verhalten zu beschreiben und zu implementieren. Der Unterschied ist, dass in Java die Implementierung innerhalb der Klasse geschehen muss. Bei Typklassen ist dies nicht der Fall. In Kotlin wird auf sogenannte Erweiterungsmethoden zurückgegriffen. Damit lassen sich für jeden bestehenden Typen neue Methoden hinzufügen. Die Typklasse Eq abstrahiert die Möglichkeit, zwei Objekte miteinander zu vergleichen, also in etwa so wie Object#equals aus Java. Dazu definiert Arrow folgendes Interface, das implementiert werden muss.
interface Eq<F> {
fun F.eqv(b: F): Boolean
fun F.neqv(b: F): Boolean =
!eqv(b)
}
Dann kann für folgende Klasse eine Instanz definiert werden: wenn zwei Benutzer identisch sind, wenn ihre ID identisch ist.
data class User(val id: Int) {
companion object
}
import arrow.extension
@extension
interface UserEq: Eq<User> {
override fun User.eqv(b: User): Boolean = id == b.id
}
Arrow liefert für alle Typklasen sogenannte Laws mit, die es ermöglichen, eigene Instanzen der Typklassen zu prüfen, ob sie auch korrekt implementiert worden sind.
Reine Funktionen
In der funktionalen Programmierung sollen Funktionen gewisse Eigenschaften haben, die es dem Entwickler erleichtern den Code zu verstehen und komplexe Probleme zu implementieren. Sie sollen total sein, das bedeutet, für alle Eingaben eine Ausgabe liefern, sie sollen deterministisch sein, also keinen Zufall enthalten, sie sollen rein sein, also keine Seiteneffekte haben, keine Mutation auslösen, also keinen globalen Zustand ändern, nie null sein, keine Reflection nutzen und keine Exception auslösen. Mit diesen Eigenschaften bekommt man Funktionen, die referenziell transparent sind, das bedeutet, dass unabhängig vom Zeitpunkt ihrer Ausführung sie immer dasselbe Ergebnis liefern. (Bildquelle: https://impurepics.com/)
Ein einfaches funktionales Programm
Dieses einfache Beispiel soll Grundlagen vermitteln, wie aus einem imperativen Programm ein rein funktionales, testbares Programm wird.
println("What is your name?")
val name = readLine()
println("Hello $name")
Dieses Programm fragt nach dem Namen des Benutzers, um ihn dann zu begrüßen. Es ist sehr einfach und leicht verständlich. Allerdings nur eingeschränkt testbar. Ob das bei so einem einfachen Programm nötig ist, sei mal dahingestellt, hier geht es ja darum, wie man die gezeigten Methoden in komplexeren Programmen umsetzen kann. Die Werkzeuge und Pattern, die verwendet werden, sind auch in der objekt-orientierten Programmierung zu finden. Zunächst kapseln wird Third-Party Systeme, hier das Lesen und Schreiben auf der Konsole in ein Interface, damit verschiedene Implementierungen zum Testen und für die Produktion bereitgestellt werden können.
interface Console<F> {
fun putStrLn(s: String): Kind<F, Unit>
fun getStrLn(): Kind<F, String>
}
class ConsoleImpl<F>(val delay: MonadDefer<F>) : Console<F> {
override fun putStrLn(s: String): Kind<F, Unit> = delay.later { println(s) }
override fun getStrLn(): Kind<F, String> = delay.later { readLine().orEmpty() }
}
Das Interface definiert die beiden notwendigen Funktionen, um etwas auf der Konsole auszugeben und etwas von der Konsole zu lesen. Die Signatur ist etwas ungewöhnlich, für die Verwendung von Arrow jedoch notwendig. Kind<F, Unit>
bzw. Kind<F, String>
entsprechen F<Unit>
bzw. F<String>
, da letzteres aber in Kotlin nicht möglich ist, wird Kind verwendet. F
ist hierbei ein generischer Effekt, in dem Unit
oder String
produziert wird. Damit die Implementierung der Konsole ConsoleImpl
Seiteneffekt frei bleibt, wird die Ein- und Ausgabe mit einer MonadDefer
verzögert. MonadDefer
liefert uns hierfür den Kontext, der erforderlich ist, um die Auswertung einer sicheren Berechnung aufzuschieben.
class MonadAndConsole<F>(M: Monad<F>, C: Console<F>) : Monad<F> by M, Console<F> by C
fun <F> MonadAndConsole<F>.fMain(): Kind<F, Unit> = fx.monad {
!putStrLn("What is your name?")
val name = !getStrLn()
!putStrLn("Hello $name")
}
Das funktionale Programm fMain sieht dem imperativen sehr ähnlich. Dies wird dadurch erreicht, dass eine Klasse MonadAndConsole definiert wird, die eine Monade und eine Console zur Instanziierung benötigt. Innerhalb der Extensionmethode sind dann die Methoden der Monade als auch der Console verfügbar. Das bedeutet, wir können eine Comprehension verwenden, um die Aufrufe der Console zu linearisieren. Damit wird aus eigentlich
fun <F> MonadAndConsole<F>.fMain(): Kind<F, Unit> =
putStrLn("What is your name?").flatMap { _ ->
getStrLn().flatMap { name ->
putStrLn("Hello $name") } }
obige Implementierung. Damit sind das Programm und die Konsole implementiert. Allerdings haben wir bei all den Definitionen noch F als Typplatzhalter, dieser muss nun noch durch IO ersetzt werden, damit das Programm auch ausgeführt werden kann. IO ist der am häufigsten verwendete Datentyp zur Darstellung von Seiteneffekten in funktionalen Sprachen. Das bedeutet, dass IO der Datentyp der Wahl ist, wenn es um die Interaktion mit der externen Umgebung geht: Datenbanken, Netzwerk, operative Systeme, Dateien. Diese Festlegung auf IO zur Verwendung für die Ein- und Ausgabe versuchen funktionale Programme so lange wie möglich hinauszuzögern, also bis zum Einstiegspunkt, also der Main-Methode.
@JvmStatic
fun main(args: Array<String>) { run {
val module = IO.monadDefer().run {
MonadAndConsole(this, ConsoleImpl(this))
}
val r = module.fMain2()
r.fix().unsafeRunSync()
}}
Damit wurde das imperative Programm in ein rein funktionales Programm umgewandelt.
Fazit
Arrow liefert die notwendigen Werkzeuge, Typklassen und Datentypen, um rein funktionalen Code in Kotlin schreiben zu können. Durch das Schreiben von rein funktionalem Code können wir einfachere Funktionen schreiben, die referenziell transparent sind und sich dadurch flexibler einsetzten und leichter testen lassen. Arrow ist der nächste logische Schritt, wenn man bereits mit Lambdas und Funktionen höherer Ordnung erste Erfahrungen gesammelt hat.