Über unsMediaKontaktImpressum
Dr. Michael Sperber 03. Mai 2016

Bessere Software-Architektur mit funktionaler Programmierung

Funktionale Programmierung fürht irgendwie zu besseren Programmen. © georgejmclittle / Fotolia.com
© georgejmclittle / Fotolia.com

Es hat sich inzwischen herumgesprochen, dass funktionale Programmierung irgendwie zu besseren Programmen führt. Aber wie genau ist funktionale Programmierung anders? In diesem Artikel geht es um typische Unterschiede funktionaler Software-Architektur zu traditioneller OO-Architektur.

Dabei konzentrieren wir uns auf drei besonders wichtige Themen: Unveränderliche Daten, reine Funktionen und Higher-Order-Funktionen. Dieses sind nur die wichtigsten Aspekte; um einen erschöpfenden Überblick zu liefern ist das Thema zu umfangreich. Damit sich die Erläuterungen nicht in vagen Allgemeinheiten verlieren, gibt es konkrete Code-Beispiele. Auch wenn sie klein sind, illustrieren sie auch, wie funktionale Programmierung im Großen funktioniert.

Das OO-Modell

Das Wichtigste an der funktionalen Programmierung sind – na klar – die Funktionen. Aber Funktionen gibt es auch in C – beziehungsweise Methoden in OO-Sprachen. Der Unterschied besteht darin, was für Funktionen funktionale Programmierer schreiben. Um den Unterschied zur OO-Programmierung deutlich zu machen, lohnt sich zunächst einmal ein Rückblick auf das klassische OO-Modell, bevor es dann um funktionale Programmierung geht.

Ein OO-Programm läuft in der ursprünglichen Konzeption von OO folgendermaßen ab: Ein Objekt schickt einem anderen Objekt eine Nachricht. Diese Nachricht führt beim Zielobjekt dazu, dass dort ein Stück Code läuft, eine Methode. Die Methode besteht aus Instruktionen, die im Allgemeinen den im Objekt gekapselten Zustand manipulieren. Zum Beispiel könnte eine Methode als Teil der Nachricht zwei Eingaben in1 und in2 akzeptieren und ein Attribut x mit folgender Anweisungsfolge manipulieren:

x = in1 
x
= x * x
x
= x + in2 * in2

Angenommen, am Anfang der Methode sind in1 an 3 und in2 an 4 gebunden. Dann setzt die erste Anweisung x auf 3. Genauer wäre, dass die erste Anweisung in die Speicherzelle, für die x steht, den Wert 3 schreibt. Die zweite Anweisung liest den Inhalt dieser Speicherzelle wieder aus, quadriert ihn und schreibt ihn in die Speicherzelle zurück: Da steht jetzt also 9. Die letzte Anweisung schließlich schreibt 25 in die Speicherzelle. Wichtig ist also:

  • Eine Variable steht für eine Speicherzelle, deren Inhalt über die Zeit wechselt. (Insbesondere steht die Variable x nicht für eine Zahl.)
  • Das Zeichen = steht nicht etwa für eine mathematische Gleichung, sondern für eine Zuweisung, die den Inhalt einer Speicherzelle ändert.

(Diese beiden Punkte sind strenggenommen Eigenschaften der imperativen Programmierung, derer sich aber fast alle OO-Programmiersprachen bedienen.)

Die Welt eines OO-Programms besteht also aus vielen Objekten, die sich Nachrichten schicken und als Reaktion darauf jeweils ihren gekapselten Zustand ändern. Die Objekte sind damit weitgehend unabhängig voneinander, gerade wegen der Kapselung.

Dieses Modell hat seinen Ursprung in der Programmiersprache Simula-67 und der Idee, dass diese Weltsicht besonders gut zu Simulationen der physikalischen Welt passt. Leider ist das nicht der Fall, da die Objekte der physikalischen Welt vielfältige Abhängigkeiten zueinander haben. Angenommen, eine Katze geht von einem Raum in den nächsten. Ein OO-Programm, das dieses simuliert, könnte folgendermaßen aussehen:

room1.exit(cat) 
room2.enter(cat)

Dieses Programm entspricht nicht der Realität, da es suggeriert, es würde sich bei dem Ablauf um zwei Schritte handeln, obwohl das Verlassen des einen Raums derselbe Schritt wie das Betreten des anderen Raums ist. Zwischen den beiden Schritten des OO-Programms ist die Katze nirgendwo, der Zustand also inkonsistent. Dies ist leider mit imperativer Programmierung kaum zu beheben: Man könnten versuchen, die Schritte umzudrehen (dann ist die Katze in beiden Räumen gleichzeitig) oder ganz anders zu organisieren, das fundamentale Problem bleibt jedoch. In der realen Welt passieren eben Dinge gleichzeitig und abhängig voneinander.

Die Ironie der Geschichte ist also, dass die OO-Programmierung ausgerechnet für das, wofür sie ursprünglich gedacht ist, besonders schlecht passt. Dies kommt zu den vielen anderen Problemen hinzu, die aus der imperativen Programmierung entstehen: Versteckte Abhängigkeiten durch gemeinsamen Zustand, Fehleranfälligkeit, Probleme bei der Parallelisierung und mehr.

Unveränderbare Daten

Die Sichtweise der funktionalen Programmierung ist anders: Ein Programm verwaltet Repräsentationen von Dingen außerhalb des Computers. Eine Simulation verwaltet also Repräsentationen von physikalischen Objekten – genauer gesagt, Repräsentationen von deren Zuständen. Wenn sich ein Zustand ändert, dann legt ein funktionales Programm eine neue Repräsentation an, ohne die alte zu ändern und damit zu zerstören. Diese Vorgehensweise kommt dem menschlichen Gehirn sehr nahe, das sich ja auch an vorige Zustände oft noch erinnern kann und "Bewegung" aus Schnappschüssen rekonstruiert.

Konkret kann das eine weitere kleine Simulation demonstrieren, diesmal als funktionales Programm in der Sprache Haskell. Es geht um Tiere auf dem texanischen Highway. Es gibt davon drei verschiedene Sorten:

  • Gürteltiere ("armadillo")
  • Klapperschlangen
  • Mäuse

Bei Gürteltieren ist bekannt, ob sie lebendig oder tot sind und wieviel sie wiegen. Bei Klapperschlangen, wie dick und wie lang sie sind. Bei Mäusen, wieviel Blut sie enthalten und wie groß sie sind. Die dazu passende Definition eines Datentyps könnte in Haskell so aussehen:

data Animal = 
Dillo { alive :: Bool, weight :: Int }
| Rattlesnake { thickness :: Int, len :: Int }
| Mouse { blood :: Int, size :: Int }

Das | ist dabei als "oder" zu lesen: Ein Tier ist ein Gürteltier ... oder eine Klapperschlange ... oder eine Maus. In Haskell reicht dies aus, um gleich ein Beispiel zu konstruieren, hier eine Liste namens h1 mit vier Beispieltieren:

h1 = [ Dillo { alive = True, weight = 10 }, 
Rattlesnake { thickness = 5, len = 100 },
Mouse { blood = 5, size = 17 },
Rattlesnake { thickness = 7, len = 200 } ]

So ein Tier auf dem texanischen Highway wird schnell mal überfahren: Das sollte natürlich in der Repräsentation des Tiers – pardon – der Repräsentation des Zustands des Tiers Niederschlag finden. Zum Beispiel sollte das alive-Flag im Gürteltier-Zustand dann False sein. Ein OO-Programm könnte das Attribut einfach ändern (also den Inhalt der Speicherzelle ...) Das geht in Haskell gar nicht, stattdessen muss das Programm eine neue Repräsentation erstellen:

runOver :: Animal -> Animal 
runOver (d@Dillo{}) = Dillo { alive = False, weight = weight d }
runOver (r@Rattlesnake{}) = r { thickness = 0 }
runOver (m@Mouse{}) = m { blood = 0 }

Das liest sich folgendermaßen: runOver ist eine Funktion, die einen Wert vom Typ Animal akzeptiert und wieder einen Wert vom Typ Animal liefert (sie akzeptiert einen Wert, der den Zustand vorher repräsentiert und liefert einen Wert, der den Zustand hinterher repräsentiert). Die Funktionsdefinition hat drei Klauseln, entsprechend den drei Klassen Tier.

Die erste Klausel beschreibt ausführlich, was passiert: Wenn ein Gürteltier überfahren ist, ist das Ergebnis ein Gürteltierzustand, in dem alive den Wert False hat und das Gewicht dem Gewicht von d (also dem Zustand vorher) entspricht. Die beiden anderen Fälle funktionieren genauso, kürzen das aber notationell ab: Das Ergebnis des zweiten Falls ist genau wie r, nur mit dem Unterschied, dass thickness 0 ist. Beim Zustand der überfahrenen Maus ist das komplette Blut herausgespritzt. In allen Fällen ist aber der alte Zustand unverändert – es werden neue Werte vom Typ Animal erzeugt.

Das ist die Essenz des größten Unterschieds zwischen OO und FP: Das OO-Programm verändert destruktiv den Zustand seiner Objekte "in situ", während das FP-Programm neue Objekte erzeugt. Funktionen, die so operieren, heißen auch "reine Funktionen" ("pure functions").

Higher-Order-Funktionen

Die Funktion runOver operiert auf "kleinen" Daten mit nur ein paar Feldern. Wie setzt sich das auf "größeren" Daten fort? Das erste Projekt ist, nicht nur das Überfahren eines einzelnen Tiers zu simulieren, sondern gleich aller Tiere auf dem Highway, die in einer Liste (wie h1 oben) stecken. Das geht so:

runOverAll :: [Animal] -> [Animal] 
runOverAll a = map runOver a

Die Typsignatur von runOverAll sagt aus, dass runOverAll eine Liste (das sagen die eckigen Klammern) von Tierzuständen akzeptiert und wiederum eine Liste von Tierzuständen liefert. Die Funktion runOverAll muss nur die vorher definierte Funktion runOver auf die Liste fortsetzen. Dazu benutzt sie die eingebaute Funktion map, die es unter diesem Namen in jeder funktionalen Sprache gibt. Um map zu verstehen, reicht eigentlich ein Blick auf die Typsignatur:

map :: (a -> b) -> [a] -> [b] 

In der Typsignatur kommen die Typvariablen (so heißen die Generics hier) a und b vor: Die map-Funktion akzeptiert zunächst eine Funktion vom Typ a -> b, die also ein a auf ein b abbildet. Außerdem akzeptiert sie eine Liste von a's (das sagt der Typ [a] aus) und produziert schließlich eine Liste von bs: Das macht map, indem sie die Funktion auf jedes Element der Eingabeliste anwendet und die Ergebnisse in einer Liste aufsammelt (In runOverAll sind a und b beide Animal).

Diese Funktion map ist eine Higher-Order-Funktion, da sie ihrerseits eine Funktion als Argument akzeptiert. Solche Higher-Order-Funktionen gibt es in funktionalen Sprachen zuhauf. Angenommen, es sollen alle toten Klapperschlangen vom Highway aufgesammelt werden. Dazu müssen diese erst einmal von den anderen Tieren unterschieden werden:

isDeadRattlesnake :: Animal -> Bool
isDeadRattlesnake (Dillo{}) = False
isDeadRattlesnake (Rattlesnake { thickness = t }) = t == 0
isDeadRattlesnake (Mouse{}) = False

Auch diese Funktion hat, da sie ein Animal akzeptiert, drei Klauseln entsprechend den drei Klassen Tier. Gürteltiere und Mäuse sind natürlich keine toten Klapperschlangen – nur Klapperschlangen, bei denen die Dicke t gleich 0 ist, kommen in Frage.

Auch hier ist es einfach, die Funktion auf Listen zu erweitern. Dazu dient die Higher-Order-Funktion filter, die folgende Signatur hat:

filter :: (a -> Bool) -> [a] -> [a] 

Auch diese Signatur enthält eine Typvariable a. Die filter-Funktion akzeptiert eine Funktion, die für ein a einen Bool – also True oder False liefert – sowie eine Liste aus a's. Dann liefert sie eine Liste von a's, nämlich genau die Elemente der Eingabeliste, bei denen die Funktion True geliefert hat. Beide können kombiniert werden zu einer Funktion, die alle toten Klapperschlangen aufsammelt:

deadRattlesnakes :: [Animal] -> [Animal]
deadRattlesnakes a = filter isDeadRattlesnake a

Auch anhand von Listen wird also der Unterschied zwischen OO und FP deutlich: Während es zum Beispiel das Interface List in Java erlaubt, Elemente destruktiv hinzuzufügen oder zu löschen, erzeugen funktionale Programme neue Listen.

Higher-Order-Funktionen bilden eine weitere Säule der funktionalen Programmierung: In Kombination mit reinen Funktionen erlauben sie die systematische Konstruktion höherstehender mächtiger Abstraktionen. Das Prinzip ist bei großen Programmen das gleiche wie bei kleinen.

Persistente Datenstrukturen

Dieses ständige Erzeugen neuer Datenstrukturen sieht auf den ersten Blick ziemlich teuer aus: Ständig neuen Speicher allozieren? Tatsächlich ist das aber mit moderner Speicherverwaltung, wie in allen modernen funktionalen Sprachen sowie auf der JVM und der .NET-CLR billiger, als viele annehmen.

Außerdem kommen in modernen funktionalen Sprachen auch moderne Datenstrukturen zum Einsatz, die den Aufwand des Erzeugens deutlich verringern. Tatsächlich entstehen ja häufig Listen aus anderen Listen, indem nur ein Element hinzugefügt, geändert oder gelöscht wird. Es bietet sich an, Datenstrukturen zu verwenden, die sich den Speicherplatz für die gemeinsamen Elemente teilen. Das ist gerade deswegen möglich, weil in diesem gemeinsamen Speicherplatz nichts geändert wird. Damit benötigt die neue Liste nur Speicherplatz für das "Delta" zur alten.

Datenstrukturen, die so funktionieren, basieren auf cleveren und nicht ganz einfachen Ideen und sind erst in den letzten 5-10 Jahren wirklich zur Reife gekommen. Dazu gehören zum Beispiel "Hash array mapped tries" oder "finger trees". Sie sind aber genauso einfach zu verwenden wie Listen und es gibt sie auch für Mengen und Maps. Fast jede moderne funktionale Sprache liefert sie mit – sie unterscheiden sich damit deutlich von den einfachen, oft Array-basierten Pendants in den OO-Klassenbibliotheken.

Der Weg zur "richtigen" funktionalen Programmierung ist noch weit.

Mit solchen sogenannten persistenten Datenstrukturen ist auch die Verwaltung großer unveränderlicher Datenmengen – etwa für große Simulationen – kein Problem mehr. Entsprechend setzen sich diese auch in Bereichen durch, die bis vor kurzem noch traditioneller imperativer Programmierung vorbehalten waren, wie im Big Data-Framework Apache Spark [1] oder beim Web-GUI-Framework React [2].

IT-Tage 2017 - Softwarearchitektur

FP + OO?

Auch wenn diese Erläuterungen den Eindruck erweckt haben sollten, es gebe einen Gegensatz zwischen FP und OOP, ist das eigentlich nicht der Fall: In der OOP (und jüngst auch im Domain Driven Design) wird schon lange empfohlen, auf value objects zu setzen, was nur ein anderes Wort für unveränderliche Daten und damit reine Funktionen ist. Tatsächlich war eine ursprüngliche Primärmotivation bei OOP, den Zustand zunächst zu kapseln und dann gänzlich zu eliminieren [3]. Umgekehrt bieten viele funktionale Sprachen auch imperative Features, die aber in der Benutzung immer mehr zurückgedrängt werden.

Neuere Versionen von OO-Sprachen nehmen zunehmend auch funktionale Elemente auf: Die Generics in Java und C# kommen jeweils aus der funktionalen Programmierung, ebenso wie jüngst Lambda-Ausdrücke in Java. Mit Streams wurden explizit weitere Ideen aus der funktionalen Programmierung in Java 8 importiert – die haben sogar map und filter.

Der Weg zur "richtigen" funktionalen Programmierung ist aber noch weit: In Java gibt es keine richtigen Funktionstypen, was die Verwendung von Lambdas oft umständlich macht und stark einschränkt. Persistente Datenstrukturen fehlen in den Collections-Bibliotheken von Java und C#. Selbst die modernen Java-8-Streams sind nicht persistent und sorgen deshalb manchmal für Überraschungen im Einsatz.

Außerdem führt die Kombination von OO und FP in einer Sprache oft zu Komplikationen, insbesondere im Typsystem. Wildcards und Subtyp- und Supertyp-Constraints bei den Java-Generics sind schwer zu verstehen, die Integration von Lambdas hat viele Jahre gedauert und ist immer noch holprig, da die JVM keine Funktionstypen versteht. Primär funktionale Sprachen sind da deutlich einfacher.

Zusammenfassung

Funktionale Sprachen sind in ihrer Vielfalt sehr facettenreich. Bei der Behandlung von "FP-Architektur" musste sich dieser Artikel auf drei wesentliche Ideen konzentrieren:

  1. konsistente, persistente Objekte
  2. reine Funktionen zur Manipulation von Daten
  3. Higher-Order-Funktionen zur Abstraktion

Auch wenn die Programme aus diesem Artikel notwendigerweise klein sind, sind diese drei Elemente meist auch für die Architektur funktionaler Programme "im Großen" maßgeblich.

nach Oben
Autor

Dr. Michael Sperber

Michael Sperber ist CTO 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.
>> Weiterlesen
Buch des Autors:

botMessage_toctoc_comments_929