Website-Icon akquinet AG – Blog

Funktionale Programmierung mit Kotlin – Was ist das eigentlich?

Die Errungenschaften funktionaler Sprachen haben längst Einzug gehalten in moderne Programmiersprachen und Frameworks. Auch Kotlin bietet hier einiges an Unterstützung an. Aber, was bedeutet funktionale Programmierung eigentlich genau? Was hat es für Vorteile? Und, ist Kotlin nicht eigentlich eine imperative Sprache, also etwas ganz anderes? Hier mal eine pragmatische Einführung in das Wesen funktionaler Entwicklung mit Kotlin.

Als der Autor dieses Texts noch an der Universität aktiv war, wurden Studenten gerne in den ersten Programmierkursen mit einer funktionalen Programmiersprache konfrontiert. Beliebt waren Haskell oder Opal, mit einem hohen Abstraktionsgrad und geringer Praxisrelevanz. Ein Proargument war, dass dadurch kein Student Vorteile hatte, der vor seinem Studium schon Programmiererfahrung sammeln konnte. Im Ergebnis hatten die meisten Studenten danach keine Lust mehr, sich mit funktionaler Programmierung zu beschäftigen. Stattdessen lag der Fokus auf imperativer Programmierung mit objektorientierten Sprachen, wie Java und C#.

Inzwischen haben sich Programmiersprachen und Frameworks weiterentwickelt. Viele Aspekte funktionaler Programmierung manifestieren sich als konkrete Sprachelemente oder als Konzepte hinter den Programmierschnittstellen von Frameworks. Da die meisten Sprachen aber dennoch einen imperativen objektorientierten Kern aufweisen, verschwimmen die Konzepte. Wenn es gut läuft, wählt eine Entwicklerin die optimal passendsten Sprachelemente aus, um ihre Anforderungen umsetzen. Wenn es nicht gut läuft, werden alle Nachteile kombiniert und es ergibt sich schlecht wartbarer fehleranfälliger Code.

Kotlin ist so eine moderne Programmiersprache, die unterschiedliche Sprachparadigmen vereinigt. Wie kann man sich nun in Kotlin auf das funktionale Paradigma beschränken? Und, warum sollte man das überhaupt tun?

Das funktionale Programmierparadigma

Mathematisch ist eine Funktion eine eindeutige Relation von einem Argumentwert auf ein Ergebniswert. Bei einer reinen funktionalen Programmierung benutzt man nur solche mathematischen Funktionen. Das bedeutet, dass jede Prozedur oder Methode für die gleiche Eingabe immer das gleiche Ergebnis liefert. Es gibt keine Seiteneffekte und damit auch keine Variablen, die ihren Zustand verändern.

Dies klingt zunächst recht einfach. Jede Programmiersprache erlaubt es auch, solche Funktionen zu definieren. Wenn man das Konzept aber durchgängig umsetzt, ändert sich sehr viel. Aufgrund der fehlenden Seiteffekte gibt es viel weniger Möglichkeiten für Überraschungen, die zu Fehlern führen.

Diese Eigenschaft nennt sich referentielle Transparenz. Woher der Name kommt, wird später noch klarer. 

Die zweite wesentliche Eigenschaft funktionaler Programmierung ist, dass Funktionen Bürger erster Ordnung sind. Klingt esoterisch, bedeutet aber nur, dass Funktionen genauso wie z.B. Variablen dynamisch erzeugt und als Eingabe- und Ergebniswerte benutzt werden können.

Ein Beispiel hierfür sind Rückrufmethoden, auch bekannt als Listener, die dynamisch erzeugt und als Eingabeparameter benutzt werden. Im Umfeld funktionaler Programmierung haben sich inzwischen viele Standardprogrammierschnittstellen entwickelt, die Wiederverwendung und Komposition von Codeteilen in Form von Funktionen vereinfachen.

Das soll an Theorie und Motivation erstmal reichen. Was bedeutet das nun konkret für Kotlin?

Unveränderliche Werte

Aus funktionaler Sicht sind Variablen nur Funktionen ohne Eingabewerte, also Konstanten bzw. benannte Werte. Das lässt sich in Kotlin syntaktisch leicht umsetzen. Alle Variablen sind mit val statt mit var zu deklarieren, schon passt der Compiler auf, dass die Variablen sich nicht ändern. 

Alleine durch die Vermeidung von var ändert sich der Programmierstil. Das folgende Listing zeigt exemplarisch die Berechnung eines Gesamtpreis durch Addition zweier Einzelpreise und Anwendung eines Rabatts in zwei Varianten. Imperativ wird eine Ergebnisvariable benutzt, die in Einzelschritten zum Gesamtergebnis transformiert wird. Funktional wird der Datenfluss der Berechnung über unterschiedliche Variablen beschrieben.

// Addieren zweier Artikel mit Rabatt

fun rabattierterPreisZweierArtikelImperativ(
      artikel1 : Artikel,
      artikel2 : Artikel,
      rabatt   : Float
    ) : Float 
{
  var ergebnis = 0.0
  ergebnis += artikel1.preis
  ergebnis += artikel2.preis
  ergebnis *= (1.0 - rabatt)
  return ergebnis
}

fun rabattierterPreisZweierArtikelFunktional(
      artikel1 : Artikel,
      artikel2 : Artikel,
      rabatt   : Float
    ) : Float 
{
  val summeArtikel =
        artikel1.preis + 
        artikel2.preis

  val rabattMultiplikator =
        1.0 - rabatt

  return rabattMultiplikator * summeArtikel
}

Interessant ist, dass der imperative Code gerne als klarer und einfacher verständlich wahrgenommen wird. Um eine einzelne Zeile in der imperativen Version zu verstehen, müssen aber mindestens alle vorherigen Schritte betrachtet werden, während in der funktionalen Variante tatsächlich jede Zeile nur lokal betrachtet werden muss. Dies erhöht die Lesbarkeit und reduziert so die Fehlerwahrscheinlichkeit.

Unveränderliche Objekte

Unveränderliche Werte sind also eine gute Sache. Meistens bestehen die Werte aber aus Objekten und die Variablen sind nur Referenzen auf Objekte. Unveränderliche Referenzen ermöglichen immer noch veränderliche Objekte. Das folgende Listing zeigt einen klassischen Fehler. Es werden allgemeine Konferenzen organisiert, die hier nur aus einem Namen und einem Preis bestehen. Konkret wird eine zweiteilige Developers-Konferenz angelegt, beide Teile kosten gleich viel. Später an einer anderen Codestelle wird ein Rabatt gegeben, z.B. für einen Teilnehmer, der sich bei beiden Teilen angemeldet hat.

data class PreisM(
    var betrag: Double
) {}

data class KonferenzM(
    var name: String,
    var kosten: PreisM
) {}

...

val developersTeil1 = KonferenzM(
  "Developers - Teil 1",
  PreisM(200.0)
)

val developersTeil2 = KonferenzM(
   "Developers - Teil 2",
   developersTeil1.kosten
)

// etwas später ...

// berechne Rabatt

developersTeil2.kosten.betrag *= 0.5

Die Codestelle sieht lokal unverfänglich aus. Allerdings weist das Objektattribut developersTeil2.kosten auf das gleiche Objekt wie developersTeil1.kosten. Eine schreibende Änderung des Objektattribut developersTeil2.kosten.betrag wirkt sich also auf developersTeil1 und auf  developersTeil2 aus. Diese Fehlerklasse ist schwierig zu analysieren, da sich Schreibzugriffe über geteilte Referenzen auf ganz andere Codebereiche auswirken. Das Suffix M in den Klassennamen soll übrigen als Abkürzung für Mutable anzeigen, dass sich die Attribute des Objekts verändern lassen.

Das nun folgende Listing zeigt das gleiche Beispiel nun funktional mit unveränderlichen Werten. Zuerst fällt auf, dass alle Objektattribute mit val als unveränderlich deklariert sind und über den Konstruktor einmalig gesetzt werden. Werte werden geändert, in dem neue Objekte als Kopien der alten mit einzelnen Änderungen erzeugt werden. Die Datenklassen von Kotlin bieten hierfür die Methode copy() an.

data class PreisIM(
    val betrag: Double
) {}

data class KonferenzIM(
    val name: String,
    val kosten: PreisIM
) {}

...

val developersTeil1 = KonferenzIM(
  "Developers - Teil 1",
  PreisIM(200.0)
)

val developersTeil2 =
  developersTeil1.copy(
    name = "Developers - Teil 2"
  )

// etwas später ...

// berechne Rabatt

val rabbatierterPreis = developersTeil2
  .kosten
  .copy(developersTeil2.kosten.betrag / 2)

val developersTeil2Rabbattiert =
  developersTeil2.copy(
    kosten = rabbatierterPreis
  )

Der Code wird dadurch meistens etwas länger, aber auch lesbarer. Wenn alle Werte unveränderlich ist, sind Seiteneffekte nicht mehr möglich und interne Objektreferenzen können beliebig heraus gegeben werden. Daher benutzen die meisten neuen Frameworks Programmierschnittstellen mit unveränderlichen Werten. Auch die Standardbibliotheken von Kotlin ermöglichen, es Listen und Abbildungen als unveränderliche Strukturen zu benutzen. Statt add() aus MutableList benutzt man z.B. einfach plus() aus List

Wo sind die Funktionen?

Bis jetzt wurde noch keine einzige Funktion gezeigt. Das liegt daran, dass wir uns bis jetzt nur mit der ersten Hälfte der funktionalen Programmierung beschäftigt haben, der referentiellen Transparenz. Die zweite Hälfte ist der Einsatz von Funktionen als „Bürger erster Ordnung“ (klingt auf Deutsch etwas komischer als auf Englisch mit “First Class Citizen”). Dieser soziopolitisch klingende Ausdruck bedeutet, dass Funktionen beliebig im Programm als Werte und Parameter erzeugt und benutzt werden können.

Als Basiskonzept für die Erzeugung von Funktionen orientiert sich Kotlin, wie die meisten anderen Programmiersprache, am Lambda-Kalkül. Dieses ist eine vollständige, formale Programmiersprache, in der nur mit seiteneffektfreien Funktionen gearbeitet wird. In Kotlin benutzt man eine dem Lambda-Kalkül ähnliche Syntax, um Funktionen zu erzeugen. So definiert der Ausdruck { x:Int , y:Int -> x + y } eine Funktion, die zwei Parameter addiert. Kotlin bietet unterschiedliche syntaktische Konstrukte an, um Funktionen zu definieren. Diese erlauben knappen und lesbaren Code, können aber auch aufgrund ihrer Unterschiedlichkeit verwirren. Es empfiehlt sich, den entsprechenden Abschnitt in der Dokumentation zu lesen.

Hier konzentrieren wir uns aber auf das Wesen funktionaler Programmierung an sich und nicht auf Syntaxvarianten. Der folgende Code zeigt eine Berechnung der Summe der Zahlen von 1 bis 100. Die Zahlen werden als Liste erzeugt. Der Typ List bietet eine Methode forEach(), die eine übergebene Funktion auf jedes Listenelement anwendet. Wir übergeben ihr eine Methode, die jedes Element zu der Variablen sum addiert. In diesem Beispiel wird eine Funktion dynamisch im Code erzeugt und als Parameter übergeben. Ist das damit schon funktionaler Code?

val list = (1 .. 100).toList()
var sum = 0

list.forEach 
  { n -> sum += n }

Natürlich nicht! Die Funktion hat einen Seiteneffekt auf die Variable sum. Damit ist die referenzielle  Transparenz verletzt. Der nächste Codeabschnitt zeigt eine funktional saubere Alternative. Statt einem seiteneffektbehafteten forEach() wird ein fold() benutzt. Dieses erhält einen Startwert 0 und eine Funktion, die den aktuellen Wert der Berechnung acc und ein Listenelement n als Eingabe erhält und dann den neuen Berechnungswert zurück liefert.

val list = (1 .. 100).toList()
val sumFold = 
  list.fold(0)
   { acc, n -> acc + n }

Da Kotlin im Kern immer noch eine imperative Sprache ist, benutzt die Implementierung von fold() aus Effizienzgründen intern eine klassische for-Schleife mit Seiteneffekt. Diese ist aber durch eine funktional saubere Schnittstelle weg gekapselt. fold() nennt man auch eine Funktion höherer Ordnung, weil sie eine Funktion als Parameter bekommt.

Dieses Beispiel zeigt an einem kleinen Beispiel sehr schön, wie sich mit dem Einsatz von Funktionen unterschiedliche Aspekte, wie eine Berechnungsstrategie über Einzelelemente von der konkreten Berechnung auf Elementebene trennen lässt. Die Berechnungsstrategie besteht aus der Zusammenfassung (Faltung) einer Menge von Werten mittels einem Startwert und einer Funktion, die aus dem aktuellen Zusammenfassungswert und einem Wert der Menge einen neuen Zusammenfassungswert berechnet. In der funktionalen Welt gibt es inzwischen eine Standardmenge dieser Berechnungsstrategien, mit denen sich sehr viele Algorithmen funktional sauber ausdrücken lassen. Es lohnt sich, sich mit diesen zu beschäftigen, aber dafür ist ein eigener Blogartikel notwendig.

Funktionen als Parameter

In folgendem Beispiel ist ein weiteres Beispiel zu sehen. Gegeben ist eine Klasse Bestellartikel, die aus einer artikelNummer und einem preis besteht. Die Aufgabe ist jetzt für eine Liste von Bestellartikeln die Summe aller Preise zu berechnen. In einer klassischen Schleife würde man alle Listenelemente durchgehen, den Kaufpreis herausziehen und alle Kaufpreise summieren. Mit Funktionen höherer Ordnung geht das einfacher, wie in summierePreise() zu sehen ist.

data class Bestellartikel(
    val artikelNummer: String,
    val preis: Int
)

fun summierePreise(items: List<Bestellartikel>): Int =
  items
    .map(Bestellartikel::preis)
    .sum()

Zunächst wird per map() aus einer Liste von Bestellartikeln eine Liste der Preise der Artikel. Diese werden dann mit sum() aufaddiert. Die Verarbeitung von Daten unter Einsatz der klassischen Container-Klassen, wie z.B. ListMap und Set, mit Hilfe von Funktionen höherer Ordnung vereinfacht die Entwicklung stark und sollte zu dem Handwerkszeug jedes Kotlin-Entwicklers gehören.

Funktionale Schleifen

Was ist nun aber mit typischen imperativen Schleifen? Der folgende Codeabschnitt enthält eine klassische numerische Schleife zur näherungsweisen Berechnung des Flächeninhalts unter einer Funktion. In dem Intervall von start bis end werden precision Funktionspunkte berechnet. Mit diesen wird die Fläche durch eine Summe von Rechtecken angenähert; mathematische Details über diese numerische Lösung der Integration über eine Funktion finden sich z.B. hier. Gesteuert wird alles über eine for-Schleife. Das Ergebnis wird in result aufaddiert.

fun integrateImperative(
      start :Double, end : Double, precision: Long,
      f : (Double) -> Double) 
      : Double {
    val step = (end-start) / precision
    var result = 0.0
    var x = start
    for ( i in 0 until precision) {
        result += f(x) * step
        x += step
    }
    return result
}

Da der Algorithmus so noch recht übersichtlich ist, sieht er erstmal trotz der Seitenefffekte recht verständlich und robust aus. Leider wird Code gerne komplexer, da sich während der Entwicklung eines Softwaresystems neue Erkenntnisse ergeben, die sich dann in einer erhöhten Komplexität widerspiegeln. Dann wird durch Seiteneffekte die Verständlichkeit erschwert. Das Problem mit der Robustheit beim imperativen Programmierparadigma kann man an der Erhöhung der X-Variablen sehen. Wenn die zwei Zeilen in der Schleife vertauscht werden, ändert sich das Ergebnis.

Der folgende Code zeigt den gleichen Algorithmus mit funktionaler Umsetzung. Anstelle einer for-Schleife wird eine Aufzählung (Range) benutzt. Der Ausdruck um die Aufzählung zu definieren (0 until precision) ist der gleiche Ausdruck, der auch in der Schleife benutzt wird. Anstelle der Bindung an eine Variable i wird die Aufzählung aber wie eine Liste benutzt. Per map() wird der Schleifenindex zuerst auf einen x-Wert abgebildet, dann auf den Flächeninhalt des Rechtecks. Über sum() werden alle Flächeninhalte aufaddiert.

fun integrateFunctional(
       start :Double, end : Double, precision: Int,
       f : (Double) -> Double) 
       : Double {
    val step = (end-start) / precision
    return (0 until precision)
            .map { index -> start + index * step}
            .map { x -> f(x) * step }
            .sum()
}

Es gibt in dieser Variante keine Seiteneffekte. Der Kontroll- und Datenfluss ist klar ersichtlich und in kleine Einheiten unterteilt. Jede Funktion könnte auch herausgezogen werden und getrennt einfach getestet werden. Bei einer Vertauschung von Codezeilen würde die Typprüfung anschlagen. Damit ist dieser Ansatz weniger fehleranfällig.

Lesbarkeit ist auch bei funktionaler Programmierung wichtig

Der Einsatz dieses funktionalen Programmierstils kann dazu verleiten, sehr lange Ketten von Funktionsaufrufen zu benutzen. Was dann fachlich berechnet wird, ist schnell nicht mehr ersichtlich. Im Sinne der Clean-Code Prinzipien sollten daher aus solchen Berechnungsketten, Zwischenergebnisse mit sprechenden Variablen heraus gezogen werden. Hier jetzt der obige Code etwas lesbarer gestaltet, indem die Zwischenergebnisse mit klar verständlich benannten Variablen herausgezogen wurden.

fun integrateFunctionalCleanCode(
       start :Double, end : Double, precision: Int,
       f : (Double) -> Double)
       : Double {
    val step = (end-start) / precision
    val xCoordinates = (0 until precision)
            .map { index -> start + index * step }
    val allRectangles = xCoordinates
            .map { x -> f(x) * step }
    return allRectangles
            .sum()
}

Mal ein Resümee

Funktionale Programmierung ist erstmal kein Hexenwerk, sondern besteht aus dem Einsatz unveränderlicher Werte und Funktionen als normale Werte, also als  First-Class-Citizen. Damit lässt sich mit etwas Übung ganz normal entwickeln und,wenn alles klappt, wird der Code robuster und wartbarer. 

Natürlich gibt es mit diesen Einschränkungen auch typische Herausforderungen, wie z.B. der Umgang mit variablen Eingabedaten oder Zufallswerten. Auch haben sich im funktionalen Bereich einige komplexe Strukturen entwickelt, die nicht jedem sofort zugänglich sind. Hierzu zählen sicherlich die berühmt berüchtigten Monaden. Und abschließend steht die funktionale Programmierung im (aus meiner Sicht unverdienten) Ruf, ineffizienter als die imperative Programmierung zu sein. Dies sind alles wichtige Themen, die in weiteren Artikel behandelt werden sollten.

Für alle, die Artikel auch gerne auf Englisch lesen, hier ein paar Links zu verwandten Artikeln von mir:

Die mobile Version verlassen