DECOUPLED BY DEFAULT
Funktionale Programmierung: Eine bessere Softwarearchitektur durch weniger Kopplung
Funktionale Programmierung verändert die Softwarearchitektur grundlegend – und das zum Besseren. Durch den Verzicht auf veränderlichen Zustand, eine präzisere Datenmodellierung und reduzierte Kopplung entstehen modularere, wartbarere und robustere Systeme.

Marcus Crestani ist erfahrener Softwarearchitekt bei der Active Group GmbH. Sein besonderes Interesse gilt der funktionalen Programmierung, Softwarearchitektur und Programmiersprachen. Außerdem hat er vielfältige Erfahrungen in der Lehre und bereits zahlreiche Schulungen und Fortbildungen erfolgreich konzipiert, organisiert, gehalten und evaluiert.

Markus ist Softwarearchitekt bei der Active Group GmbH in Tübingen. Wir entwickeln Software ausschließlich mit funktionaler Softwarearchitektur. Markus interessiert sich neben Programmiersprachen und Compilerbau auch für GUI-Design, Nebenläufigkeit und Formale Methoden.
Decoupled by Default
Wenn funktionale Programmierung zum Einsatz kommt, wird die Architektur besser. Das leisten Designtechniken, die gegenüber objektorientierter Programmierung Kopplung von vornherein senken und niedrig halten: allen voran der Verzicht auf einen veränderbaren Zustand und die präzise Modellierung von Daten.
Formbarkeit ist die zentrale Qualitätseigenschaft jedes großen Softwaresystems. Der gesamte Lebenszyklus einer Software besteht in der Überführung der Software von der einen Form in eine andere. Die Formbarkeit ist damit der fundamentale Ermöglicher aller anderen Qualitäten wie Effizienz, Robustheit oder Gebrauchstauglichkeit.
Formbarkeit
„Wartbarkeit“ oder „Veränderbarkeit“ ist der in diesem Kontext häufiger verwendete Begriff. In unseren Ohren klingt „Wartung“ oder „Veränderung“ allerdings nach einer Aktivität, die lediglich nach der Fertigstellung geschieht. „Formbarkeit“ ist allgemeiner. Formbar muss ein Softwaresystem von Anfang an sein, denn der gesamte Lebenszyklus eines Systems besteht in dessen Formung.
Die Formbarkeit (Kasten: „Formbarkeit“) einer Software wird maßgeblich durch ihre Modularisierung bestimmt. Module erlauben die isolierte Arbeit an einem kleinen Teil der Software. Mit der nominellen Festlegung von Modulgrenzen allein ist aber noch nichts gewonnen. Die einzelnen Module sollten auch inhaltlich modular sein. Ein Maß für diese Modularität ist die sogenannte Kopplung. Ein Programmstück A ist stark an ein anderes Programmstück B gekoppelt, wenn es viele Abhängigkeiten von A nach B gibt. Das hat zur Folge, dass Änderungen in B mit hoher Wahrscheinlichkeit auch Änderungen in A nach sich ziehen. Wenn unsere Software also aus vielen solchen stark gekoppelten Teilen besteht, müssen wir bei den kleinsten Anforderungsänderungen fast die gesamte Software anfassen. Besser für die Formbarkeit wäre es, wenn Änderungen lokal blieben.
Die herkömmliche Sicht auf Kopplung ist die, dass hohe Kopplung dann entsteht, wenn wir beim Programmieren nicht aufpassen: wenn wir nicht gegen explizite Schnittstellen programmieren, wenn wir nicht modularisieren, wenn wir unsere Aggregate falsch wählen und so weiter. Kopplung, so scheint es, ist das Resultat von Unterlassung. Die Kopplung wieder zu senken, erfordert dann erhöhten Arbeitsaufwand: Refactoring-Maßnahmen.
Organisationen wie das International Software Architecture Qualification Board (iSAQB) [1] oder das Software Engineering Institute (SEI) [2] legen uns Softwarearchitekt:innen einen ganzen Katalog sogenannter Taktiken nahe, die wir anwenden können, um die Formbarkeit zu erhöhen, sprich: die Kopplung zu senken. Dort stehen Maßnahmen wie „Einhaltung des Open/Closed-Prinzips“ oder der Einsatz von Entwurfsmustern.
Wenn der sprichwörtliche Karren bereits im Dreck steckt, dann ist der Einsatz von Taktiken sicherlich sinnvoll. Wir müssen den Karren schließlich wieder aus dem Dreck ziehen und das erfordert Arbeit. Auch beim Karren könnte man bei der Problemanalyse zu dem Ergebnis kommen, dass es Unterlassungen waren, die zur Misere geführt haben. Der Fahrer hat zu spät Schneeketten aufgezogen oder er hat es schlicht versäumt, ein Auto mit Allradantrieb zu kaufen. Gleichzeitig ist aber auch klar: Der eigentliche Ursprung des Problems liegt nicht in der Unterlassung – der Nicht-Tat –, sondern in der Tat: Jemand ist in den Dreck gefahren. Was hat der Karren überhaupt im Dreck zu suchen?
Besser als den Karren ständig aus dem Dreck zu ziehen, wäre es doch, wenn wir ihn gar nicht erst in den Dreck hineinfahren würden. Bei der Kopplung ist es dasselbe: Besser als ständig Maßnahmen zur Senkung der Kopplung ergreifen zu müssen, wäre es doch, wenn wir diese Kopplung gar nicht erst einführen würden. Die Ursünde der Softwarearchitektur ist nicht die Unterlassung heilender Gegenmaßnahmen, sondern die Durchführung schädlicher Maßnahmen.
Common Coupling
Eine dieser schädlichen Maßnahmen ist das routinehafte Programmieren mit veränderlichem Zustand. Das führt zum sogenannten Common Coupling. Es ergibt sich immer dann, wenn verschiedene Module dieselben Daten verändern. Dass ständig mit veränderlichem Zustand programmiert wird, ist nicht primär das Vergehen der Programmierer:innen, sondern schon das der Programmiersprachen. Der Standard des Umgangs mit Daten in allen populären Programmiersprachen wie Java, C++ oder Python ist, dass Daten veränderlich sind. In diesen Sprachen erfordert es explizite Maßnahmen, um diese Quelle von Kopplung zu entschärfen – beispielsweise durch Bibliotheken für persistente Datenstrukturen, die unveränderlich sind.
In der funktionalen Programmierung ist die Standardeinstellung genau das Gegenteil: Daten sind unveränderlich. Wenn wir mit Programmierer:innen mit klassischer OO-Ausbildung sprechen, werden wir oft gefragt, warum wir uns dieses Bein mit den unveränderlichen Daten stellen. Aus deren Sicht ist die Unveränderlichkeit eine Einschränkung des Normalzustands. Falls wir in der funktionalen Programmierung dann doch mal mit Zustand programmieren wollen (weil das die Struktur des Problems vorgibt), müssen wir einen Extraaufwand betreiben und beispielsweise zu einer Monade greifen.
Tatsächlich wird man feststellen, dass erstaunlich wenige Probleme inhärent mit Zustand zu tun haben. Die Essenz der meisten Probleme lässt sich viel direkter mit puren Funktionen beschreiben. Wer nun allerdings eine Programmiersprache mit der Werkseinstellung „Veränderlichkeit“ zur Hand hat, programmiert dort natürlich auch mit veränderlichen Daten. Der Grad der Kopplung in den resultierenden Programmen ist beträchtlich höher. Jedes Datenelement besteht jetzt nämlich eigentlich aus zwei Konzepten: dem Datenelement an sich und einem Ort für das Datenelement. Das hat weitreichende Konsequenzen, denn damit ist auch jedes Stück Code, das mit diesen veränderlichen Daten arbeitet, auf einmal Code, dessen Richtigkeit von Veränderungen abhängt.
Zur Illustration ein kleines Beispiel. Wir programmieren eine Software zur Verwaltung von Personalvermittlungen in Java (Listing 1).
Listing 1
public class Person {
private String name;
private String address;
…
public void setAddress(String newAddress) {
this.address = newAddress;
}
}
public class A {
private Person myPerson;
…
public void receivePerson(Person p) {
myPerson = p;
}
}
public class B {
private Person myPerson;
…
public void transferPerson(A other) {
// Transfer to other department
other.receivePerson(myPerson);
}
public void backToTheOffice() {
myPerson.setAddress(„Seattle“);
}
}
Die Klasse Person beschreibt eine veränderliche Person. Die Signatur von setAddress sagt zwar, dass nichts zurückkehrt (void), aber das bedeutet nicht, dass gar nichts geschieht. Die gegebene Person wird verändert. Darunter sehen wir zwei Verwender:innen A und B. Das könnten Organisationen sein, die Personal (ver)leihen. A kann mit receivePerson eine Person als neue:n Mitarbeiter:in empfangen. B kann eine Person mit transferPerson an A transferieren. B kann außerdem mit backTotheoffice die Adresse des/der Mitarbeiter:in verändern.
Obiger Code ist kein außergewöhnlicher Java-Code. Es passiert nicht viel, aber dennoch mehr als nötig. Die Werkseinstellung von Java ist, dass Objekte als Referenz übergeben werden. In anderen Worten: receivePerson nimmt nicht einfach die Informationen zu einer Person, sondern sie nimmt einen Ort, an dem diese Informationen liegen. Das hat verheerende Konsequenzen. A kann sich nicht sicher sein, ob die Informationen, die an diesem Ort liegen, eventuell verändert werden. In der Tat zeigt die Signatur von B ja auch noch die Methode backToTheOffice. Das lässt Schlimmes ahnen. Wir müssen hier ziemlich vorsichtig programmieren, um nicht den Fall zu produzieren, dass eine Person, die von B an A übergeben wurde, auf einmal von B mit backToTheOffice zurück ins Büro gerufen wird.
Zusätzlich zur Kopplung von B an A über transferPerson ist auch A an B gekoppelt – insbesondere an dessen Methode backToTheOffice. Alle Entwickler:innen, die lediglich eine Änderung an der Klasse A vornehmen möchten, müssen sich jetzt auch über viele Eigenschaften aller Verwender:innen ihrer receivePerson-Methode informieren. Modularität sieht anders aus.
Vorsichtige Verwender:innen, also vorsichtige Programmierer:innen der Klasse A, würden deshalb vielleicht in receivePerson eine Kopie der übergebenen Person machen und diese Kopie an einen neuen, nicht geteilten Speicherort legen. Das wäre eine klassische Maßnahme zur Senkung der Kopplung – allerdings eine, die in funktionalen Programmiersprachen nicht nötig gewesen wäre: Ein geteilter Zustand ist in purer funktionaler Programmierung schlicht ausgeschlossen.
Das ist für die Kopplung ein Vorteil und für die Ausdrucksfähigkeit kein Nachteil. Die meisten Probleme lassen sich viel direkter mit puren Funktionen und unveränderlichen Daten lösen. Für den Rest gibt es auch in funktionalen Programmiersprachen Konstrukte für den Umgang mit einem veränderlichen Zustand, zum Beispiel Monaden. Programmiersprachen mit starkem Typsystem wie Scala schreiben Programmierer:innen dann einen sicheren Umgang mit diesen Konstrukten vor, das heißt, es wird verhindert, dass einer Funktion, die einen unveränderlichen Wert als Argument erwartet, ein veränderlicher Wert untergeschoben wird.
Natürlich muss auch ein funktionales Programm irgendwann einmal Werte in eine Datenbank schreiben, eine grafische Benutzungsoberfläche anzeigen oder eine Datei lesen, und funktionale Programmiersprachen wie Scala lassen das auch zu. An dieser Nahtstelle warten allerdings wieder einige versteckte Infektionsherde für Kopplung und auch hier kann die funktionale Softwarearchitektur Linderung schaffen.
External Coupling
External Coupling ergibt sich immer dann, wenn ein Programm von Dingen außerhalb des Programms abhängt, also zum Beispiel von Datenbanken, grafischen Benutzungsoberflächen, Dateisystemen oder Hardware – also allem, was unter den Begriff Infrastruktur fällt und bei dem Zustand und nichtdeterministische Abläufe ins Spiel kommen. Die Implementierung oder Anbindung solcher Funktionalität verleitet Programmierer:innen gern dazu, zu beschreiben, wie etwas passieren soll, anstatt zu beschreiben, was das Programm machen, also was passieren soll. Etwa wie die Datenbank dazu benutzt werden muss, um die Daten abzulegen, oder wie das Dateisystem dazu gebracht werden muss, den Inhalt einer Datei zu lesen. Und in diesem Wie stecken dann die Details, die unsere Programmteile sehr stark an das Stück Infrastruktur koppeln. Diese Details müssen Programmierer:innen dann an allen Stellen im Programm anpassen, wenn sich ein Stück Infrastruktur ändert.
Natürlich kommt man nicht darum herum, die Details zur Einbindung von Infrastruktur zu programmieren. Die tatsächlichen Details, wie die Datenbank angesteuert oder die Bibliothek der grafischen Benutzungsoberfläche verwendet werden will, müssen ja im Programm vorkommen. Aber die Ausführung direkt zu spezifizieren, kommt mit dem Nachteil starker Kopplung. Zudem sind derart gestaltete Programme nur schwer und mit viel Aufwand automatisiert testbar, da für die automatisierten Tests auch die Infrastruktur, also zum Beispiel die Datenbank, benötigt wird. Gut ausgebildete Programmierer:innen führen Schnittstellen ein und verwenden Dependency Injection, was die Situation schon deutlich verbessert, aber auch hier ist die Gefahr groß, dass die eingeführten Schnittstellen zu viele Details aus der Infrastruktur beinhalten und damit zu stark koppeln. Ein konkretes Beispiel dafür findet sich oft beim Umgang mit Datenbankverbindungen, wo ein spezielles Session-Objekt zunächst erzeugt und dann für jede Interaktion weitergereicht werden muss.
Funktionale Programmierer:innen sind es gewohnt, zu beschreiben, was ein Programm machen soll, ohne früh über Infrastruktur nachzudenken. Sie schreiben sich gern domänenspezifische Sprachen, um die Essenz dessen, was passieren soll, zu beschreiben. Mit Programmierabstraktionen wie Monaden oder Effekten können sie domänenspezifische Sprachen implementieren und isolieren damit die Infrastruktur vom eigentlichen Programm. Und genau das führt automatisch dazu, dass Programme von der Infrastruktur entkoppelt sind – dass also External Coupling in funktionaler Softwarearchitektur gar nicht erst vorkommt.
Wir stellen uns vor, dass unser funktionales Scala-Programm Informationen persistieren soll. Die Daten, die das Programm speichert, sind recht einfach aufgebaut: Werte sollen anhand eines Schlüssels abgelegt und wiedergefunden werden können. In unserem Beispiel soll der Schlüssel eine Zeichenkette sein. Anhand dieser Beschreibung können wir die nötige Funktionalität direkt als Datentyp modellieren. Wir legen fest, dass ein Schlüssel Key dem Typ String entspricht, und definieren zwei Datenbankoperationen in Form gemischter Daten als Aufzählung DBOp. Der Typparameter [A] bestimmt dabei den Rückgabetyp unserer Datenbankoperationen:
type Key = String;
enum DBOp[A] {
case Put[T](key: Key, value: T) extends DBOp[Unit]
case Get[T](key: Key) extends DBOp[Option[T]]
}
Unsere zwei Datenbankoperationen:
- Die Datenstruktur Put beschreibt eine Operation, die einen Schlüssel key und einen beliebigen Wert value akzeptiert und nichts – sprich () – zurückliefert.
- Die Datenstruktur Get beschreibt eine Operation, die einen Schlüssel key akzeptiert und den zugehörigen Wert zurückgibt, falls es diesen gibt (kodiert über Scalas Option-Datentyp).
Cats [3] ist eine Scala-Bibliothek, die unter anderem Monaden nach Scala bringt. Sie ermöglicht es, aus den Datenstrukturen monadische Operationen zu machen und auszuwerten. Zunächst definieren wir die DB-Monade und machen aus den Fällen der DBOp-Aufzählung monadische Operationen (Listing 2).
Listing 2
import cats.free.Free
type DB[A] = Free[DBOp, A]
import DBOp.*
import cats.free.Free.liftF
// Put returns nothing (i.e. Unit).
def put[T](key: Key, value: T): DB[Unit] =
liftF[DBOp, Unit](Put[T](key, value))
// Get returns a T value.
def get[T](key: Key): DB[Option[T]] =
liftF[DBOp, Option[T]](Get[T](key))
Außerdem hätten wir des Komforts wegen gern noch eine Operation, die gespeicherte Werte aktualisiert. Diese Funktionalität können wir mit den existierenden Operationen umsetzen, indem wir die Operationen mit einer for-Comprehension verknüpfen:
def update[T](key: Key, f: T => T): DB[Unit] =
for {
vMaybe <- get[T](key)
_ <- vMaybe.map(v => put[T](key, f(v))).getOrElse(Free.pure(()))
} yield ()
Die Operation update nimmt einen Schlüssel key, wendet die übergebene Funktion f auf den gefundenen Wert an und speichert das Ergebnis wieder unter dem Schlüssel ab.
Mit diesen Operationen können wir nun beispielsweise ein Programm schreiben, das Populationszahlen von Tieren speichert, verändert und ausliest. Wir schreiben, was passieren soll (Listing 3).
Listing 3
def program: DB[Option[Int]] =
for {
_ <- put(„dillo“, 11)
_ <- put(„parrot“, 5)
_ <- update[Int](„dillo“, (_ + 3))
n <- get[Int](„dillo“)
} yield n
Wir erwarten, dass das Programm Some(14) liefert, die Populationszahl der Dillos. Aber um das Ergebnis tatsächlich zu berechnen, fehlt noch etwas. Bisher haben wir nur die Operationen und ein Programm definiert, nicht jedoch, wie die Operationen eines Programms ausgeführt werden sollen. Der Wert von program ist eine Repräsentation unserer Berechnung vom Typ DB, der weiter oben definierten Monade. Eine mögliche Definition der Ausführung der Operationen sieht so aus, wie es Listing 4 zeigt.
Listing 4
import cats.data.State
type DBState[A] = State[Map[Key, Any], A]
val stateMapRunner: DBOp ~> DBState =
new (DBOp ~> DBState) {
def apply[A](fa: DBOp[A]): DBState[A] =
fa match {
case Put(key, value) =>
State.modify(_.updated(key, value))
case Get(key) =>
State.inspect(_.get(key).asInstanceOf[A])
}
}
def eval1[A](program : DB[A]): A =
program.foldMap(stateMapRunner).run(Map.empty).value._2
val result1: Option[Int] = eval1(program)
// = Some(14)
Der stateMapRunner benutzt eine Map, um die Daten zu persistieren. Der komische Pfeil ~> irritiert vielleicht etwas: DBOp ~> DBState ist ein Interface für die Übersetzung zwischen zwei Monaden, in diesem Fall zwischen unserer Datenbankmonade und der von Cats mitgelieferten State-Monade, die zusätzlich zum eigentlichen Ergebnis der Berechnung auch den Zustand der Monade als Map[Key, Any] kodiert enthält.
Wir implementieren die Operationen Put und Get so, dass sie mit modify und inspect die Map der Zustandsmonade bedienen. Die Zustandsmonade übergibt die Map transparent von Berechnungsschritt zu Berechnungsschritt. Die Methode eval1 wertet mit stateMapRunner aus und liefert das richtige Ergebnis Some(14). Zudem versteckt die Methode eval1 auch das Implementierungsdetail, dass der Zustand im Rückgabewert der Ausführung enthalten ist. Das Programm und die Schnittstelle zur Ausführung sind also nicht an dieses Implementierungsdetail gekoppelt.
Die Methode eval1 eignet sich hervorragend zum Testen eines gegebenen Datenbankprogramms, da sie die Ausführung von Testfällen ohne Infrastruktur, also hier ohne Datenbanksystem, ermöglicht. Zusätzlich erlaubt die Trennung von Beschreibung und Auswertung, dass wir noch andere Arten der Ausführung definieren. Auch ein echtes Datenbanksystem anzubinden ist einfach und transparent möglich, Listing 5 zeigt eine beispielhafte Definition der Ausführung dafür.
Listing 5
import cats.effect.IO
object Database {
def insert[T](key : Key, value : T) : IO[Unit] = ???
def select[T](key : Key) : IO[Option[T]] = ???
}
val databaseRunner: DBOp ~> IO =
new (DBOp ~> IO) {
def apply[A](fa: DBOp[A]): IO[A] =
fa match {
case Put(key, value) =>
Database.insert(key, value)
case Get(key) =>
Database.select(key)
}
}
def eval2[A](program : DB[A]): IO[A] =
program.foldMap(databaseRunner)
val result2: Option[Int] = eval2(program).unsafeRunSync()
Der Typ der Auswertung ist IO – eine zustandsbehaftete Berechnung in der Infrastruktur. Das kann die Methode eval2 nicht verstecken, hier sind wir jetzt tatsächlich am Rand unseres funktionalen Kerns, nämlich in der imperativen Hülle („Functional Core, Imperative Shell“ [4] ist ein Entwurfsmuster aus der funktionalen Softwarearchitektur): Dort müssen wir die Auswertung machen und Fehler und Ausnahmefälle behandeln. Weil wir die Auswertung aber in der Hand haben, müssen wir erst ganz außen auf Ausnahmefälle eingehen – und dort können wir das dann auch passend mit imperativen Effekten aus anderen Teilen der Infrastruktur machen. Die komplizierte Bedienung der echten Datenbank leckt aber nicht ins Programm.
Die Berechnung in databaseRunner ist effektbehaftet – sie verändert den Zustand beziehungsweise interagiert mit der nichtfunktionalen Infrastruktur. Und das ist richtig so: In der funktionalen Programmierung geht es nämlich nicht darum, Effekte komplett zu verhindern, sondern es geht darum, Effekte ganz gezielt an den Grenzen zur Infrastruktur zu kapseln und zu kontrollieren – hier in den Interpretern der monadischen Operationen – und so Kopplung gar nicht erst entstehen zu lassen.
Content Coupling
Wie wir gesehen haben, sind viele Arten von unnötiger Kopplung in der funktionalen Programmierung ausgeschlossen oder zumindest leichter zu umgehen. Es gibt aber mindestens eine Art von Kopplung, die sich auch funktionale Programmierer:innen häufig einfangen: das sogenannte Content Coupling. Content Coupling ergibt sich immer dann, wenn zwei Module über dieselbe Datenstruktur sprechen und dabei mehr über die Datenstruktur wissen als eigentlich nötig. Das klassische Beispiel für Content Coupling ist eine Datenstruktur für Wertemengen: Set. Sets werden aus Gründen der Effizienz oft mit Hilfe balancierter Bäume implementiert. Wer nun versäumt, ein ordentliches Interface für die Set-Struktur zu definieren und stattdessen alle Verwender:innen unmittelbar auf die balancierten Bäume loslässt, hat einen enormen Grad an unnötiger Kopplung erzeugt.
Content Coupling wird oft als Verletzung des Information-Hiding-Prinzips diskutiert: Es wurde wie beschrieben versäumt, Informationen vor den Verwender:innen zu verstecken. Information-Hiding ist eine negative Bestimmung des gewünschten Zustands: Etwas soll versteckt werden. Was aber soll denn übrigbleiben? Anstatt auch hier nachträglich den Karren aus dem Dreck zu ziehen, wollen wir versuchen, ihn gar nicht erst in diese Bredouille zu bringen. Dabei helfen uns zwei fortgeschrittene Techniken der funktionalen Softwarearchitektur: „Make Illegal States Unrepresentable“ und die denotationelle Semantik.
Zeitreihen
Wir stellen uns vor, wir müssten ein externes System ansprechen, das eine Zeitreihe verwaltet. Wir könnten versucht sein, folgende Schnittstelle zu schreiben:
object TimeSeriesServiceLegacy {
def getTimeSeriesData(from: Time, to: Time)
: List[(Time, Double)] = …
}
Wir können also Ausschnitte der Zeitreihe abfragen, indem wir einen Start- und einen Endzeitpunkt angeben. Als Antwort kommt dann eine Liste von Zeit-Wert-Tupeln zurück. Das sieht doch eigentlich recht lose gekoppelt aus, oder? Die scheinbar einzigen Abhängigkeiten, die wir uns hier als Verwender:innen einfangen, sind durch die beiden Parameter und den Rückgabetyp beschrieben. Dennoch ist hier mehr Kopplung eingebaut als wirklich nötig. Um das einzusehen, betrachten wir einige Beispiele für Zeitreihen, die durch Listen von Tupeln beschrieben sind (Listing 6).
Listing 6
// Seien t1, t2, t3 beliebige Zeitpunkte
// (sprich: Werte vom Typ Time) mit t1 < t2 < t3
val ts1 = List((t1, 5.0), (t2, 6.5), (t3, 7.3))
val ts2 = List((t2, 6.5), (t1, 5.0), (t3, 7.3))
val ts3 = List((t1, 6.5), (t1, 6.5))
val ts4 = List((t1, 6.5), (t1, 13.4))
Die Liste ts1 beschreibt eine Zeitreihe, wie wir sie erwarten würden: Drei Tupel mit sortierten Zeitpunkten. Die Liste ts2 ähnelt ts1, allerdings sind die ersten beiden Elemente der Liste vertauscht. Was haben wir uns also unter ts2 vorzustellen? Beschreibt ts2 dieselbe Zeitreihe wie ts1? Ist ts2 schlicht ein illegaler Ausdruck in diesem Kontext? Ein noch kniffligerer Fall ist ts3. Hier kommt der Zeitpunkt zweimal mit demselben assoziierten Wert vor. Ist das in Ordnung oder wollen wir auch diesen Fall als illegal ansehen? Und was ist mit ts4? Dort kommt t1 auch zweimal vor, jetzt allerdings mit zwei unterschiedlichen Werten.
Alle diese Fragezeichen deuten darauf hin, dass der Rückgabetype von getTimeSeriesData – also List[(Time, Double)] – noch nicht alle notwendigen Informationen der Schnittstelle beschreibt. Das ist insofern problematisch, da sich mit diesen vertrackten Fragen alle Verwender:innen der Schnittstelle befassen müssen, und alle müssen auch zu denselben Antworten kommen. Uns liegt ein Fall von impliziter Kopplung vor.
Implizite Kopplung explizit machen mit „Make Illegal States Unrepresentable“
Anstatt uns mit dieser Art der impliziten Kopplung abzufinden – das wäre die Standardherangehensweise – wollen wir ein fortgeschrittenes Muster der funktionalen Softwarearchitektur nutzen: „Make Illegal States Unrepresentable“. Was wie ein Schlachtruf aus der Anfangszeit der Vereinigten Staaten klingt, ist tatsächlich ein Merksatz aus der funktionalen Datenmodellierung. Wie wir an den Beispielzeitreihen oben gesehen haben, gibt es einige Fälle („States“), die wir eigentlich nicht zulassen möchten („illegal“). Wir sollten deshalb versuchen, diese Fälle gar nicht erst möglich zu machen („make unrepresentable“), was uns mit Hilfe passenderer Datenstrukturen gelingt. In unserem Beispiel suchen wir eine Struktur, die folgende Einschränkungen umsetzt:
- Eine Zeitreihe bezieht sich auf eine Menge von Zeitpunkten.
- Jeder Zeitpunkt aus dieser Menge ist mit genau einem Double-Wert verknüpft.
Nach ein wenig Bedenkzeit könnten wir auf den Gedanken kommen, dass eine assoziative Map-Datenstruktur genau diese beiden Anforderungen erfüllt. Wir legen dann einen sogenannten Anti-corruption Layer zwischen den TimeSeriesServiceLegacy von oben und unseren restlichen Anwendungscode. Dieser neue Layer spricht den TimeSeriesServiceLegacy an und behandelt die oben genannten Sonderfälle dergestalt, dass die List[(Time, Double)] in eine Map[Time, Double] überführt wird (Listing 7).
Listing 7
object TimeSeriesServiceAnticorruption {
private def parseList(lst: List[(Time, Double)], acc: Map[Time, Double]): Map[Time, Double] =
lst match {
case Nil => acc
case (t, x) :: rest => parseList(rest, acc + (t -> x))
}
def getTimeSeriesData(from: Time, to: Time): Map[Time, Double] =
parseList(TimeSeriesServiceLegacy.getTimeSeriesData(from, to), Map.empty)
}
Alle oben aufgeworfenen Fragen sind jetzt in TimeSeriesServiceAnticorruption gebündelt beantwortet. Bei der Verwendung dieses Layers müssen wir uns über diese Fragen und Antworten nicht mehr den Kopf zerbrechen. Dass wir die Kopplung erfolgreich gesenkt haben, kann man sich anhand eines kleinen Szenarios veranschaulichen. In der Implementierung oben haben wir uns dafür entschieden, dass duplizierte Einträge in der Liste einfach überschrieben werden – und zwar so, dass der letzte Eintrag gewinnt. Vielleicht war diese Entscheidung falsch. Vielleicht kommt etwas später im Entwicklungsprozess die Anforderung, dass immer der erste Eintrag gewinnen soll. Kein Problem: Es gibt nur genau eine Stelle, die wir im Code verändern müssen, um dieser neuen Anforderung gerecht zu werden. Niemand, der unser TimeSeriesServiceAnticorruption verwendet, muss sich mit dieser Änderung befassen.
Zeitreihen mit denotationeller Semantik
Mit der Beschreibung der Schnittstelle als Map[Time, Double] haben wir erreicht, dass der Scala-Typchecker uns bei der Prüfung hilft, ob ein:e Implementierer:in die Schnittstelle einhält und ein:e Nutzer:in sie richtig verwendet. Gleichzeitig haben wir aber den Raum der Möglichkeiten sehr eingeschränkt. Der/die Implementierer:in muss jetzt eben auch eine Map zurückgeben; eine andere Wahl ist ausgeschlossen. Das könnte im Lebenszyklus unserer Software zu einem Problem werden. In einem System mit solch rigiden Schnittstellen werden Anforderungsänderungen höchstwahrscheinlich nicht nur Änderungen innerhalb der Module, sondern auch Änderungen der Schnittstellen zwischen Modulen nach sich ziehen.
Vielleicht haben wir mit Map[Time, Double] die Schnittstelle zu stark eingeschränkt. Manche Zeitreihen lassen sich mit dieser Modellierung gar nicht ausdrücken, beispielsweise die Zeitreihe, die schlicht zu jedem Zeitpunkt den Wert 3.14 beschreibt. Falls eine neue Geschäftsanforderung erfordert, dass wir auch solche Zeitreihen behandeln können, müssen wir wieder mehrere Module gleichzeitig ändern.
Denotational Design ist ein Modellierungsansatz, der hier Abhilfe schafft. Die Idee ist so einfach wie radikal. Beim Denotational Design trauen wir uns etwas, das sich sonst fast niemand traut: Wir fragen uns, was wir eigentlich tun. Im Beispiel unserer Zeitreihen fragen wir uns konkret: Was ist eine Zeitreihe? Denotational Design basiert auf der sogenannten denotationellen Semantik. Dort ist die Form der Antwort auf die Was-Frage eine rein mathematische Struktur. Um die Frage „Was ist eine Zeitreihe?“ mit denotationeller Semantik zu beantworten, müssen wir Zeitreihen mit Hilfe von Mengen, Funktionen, Tupeln etc. beschreiben. Dabei wollen wir nicht einfach alle Aspekte des ursprünglichen Programms mit mathematischen Mitteln nachbauen, sondern uns auf die Essenz konzentrieren.
Wenn wir eine Weile mit diesem Impetus über die Frage „Was ist eine Zeitreihe?“ nachdenken, dann kommen wir zur Einsicht, dass eine Zeitreihe jeden Zeitpunkt mit maximal einem Double-Wert verknüpft. Der mathematische Werkzeugkasten bietet mit partiellen Funktionen ein Konstrukt, auf das diese Beschreibung genau passt. Partielle Funktionen können wir in Scala modellieren als Funktionen, die in den Option-Typ abbilden. Wir können unseren Code oben ein wenig refaktorisieren, um diesen Umstand explizit auszudrücken (Listing 8).
Listing 8
sealed trait TS extends Function1[Time, Option[Double]]
class TSList(list: List[(Time, Option[Double])]) extends TS {
def apply(t: Time): Option[Double] =
list.find( (tx, _) => tx == t ).flatMap(_._2)
}
trait TimeSeriesService2 {
def getTimeSeriesData(from: Time, to: Time): TS
}
Der Rückgabetyp ist jetzt nur noch TS, ein Typ, der lediglich anzeigt, dass er einen Funktions-Trait implementiert. Unsere einzige Implementiererin dieser Klasse ist TSList. Wir haben hier also eine kleine Indirektion eingebaut, die den Unterschied der Essenz (Function1[Time, Option[Double]]) von der konkreten Repräsentation (TSList) verdeutlicht. Die Klasse TSList muss die oben genannten Fragen beantworten – hier wurde wieder entschieden, dass für jeden Zeitpunkt der erste gefundene Eintrag zählt. Im Kontrast dazu muss sich ein:e Verwender:in der TimeSeries-Klasse jetzt nicht mehr mit diesem komplexen Fragenkatalog befassen. Die Ausgabe von getTimeSeriesData ist im Wesentlichen eine Funktion und Funktionen kann man nur anwenden. Wir haben die Kopplung erheblich gesenkt.
Mit der Einführung dieser Indirektion haben wir uns jetzt auch aufseiten der Implementierung des Zeitreihenmoduls Freiräume geschaffen: TSList muss ja keinesfalls die einzige Repräsentation von Zeitreihen bleiben. Eine offensichtliche zweite Repräsentation wären natürlich Funktionen selbst.
class TSFun(f: Time => Option[Double]) extends TS {
def apply(t: Time): Option[Double] = f(t)
}
Plötzlich können wir Zeitreihen ausdrücken, die vorher undenkbar waren, beispielsweise die Zeitreihe, die zu jedem Zeitpunkt einen festen Wert liefert. Wir haben in diesem Beispiel den Typ Time gar nicht näher bestimmt. Das ist Absicht. Dieser Typ kann abstrakt bleiben; die einzige Anforderung, die wir haben, ist, dass die Werte eine totale Ordnung bilden. Die größte intellektuelle Reichweite hat unser Zeitreihenmodell übrigens, wenn es zwischen jeweils zwei Zeitpunkten – Werten des Typs Time – eine unendliche Menge Zeitpunkte gibt, die dazwischen liegen. Es sollte klar sein, dass es spätestens dann unmöglich ist, die Zeitreihe immerPi mit einer (endlichen) Liste von Tupeln auszudrücken.
val immerPi: TS = TSFun (t => Some(3.14) )
Schnittstellendesign mit Hilfe von Denotational Design senkt natürlicherweise die Kopplung zwischen Bausteinen, denn wir möchten ja die Essenz einer Schnittstelle beschreiben und damit beschreiben wir die notwendigen und hinreichenden Informationen, die über die Schnittstelle fließen. Mit ein wenig Übung im Umgang mit denotationeller Semantik erübrigt sich vielerorts das ständige Räsonieren über Kopplung. Lose Kopplung ergibt sich fast beiläufig aus Schnittstellen, die mit Hilfe denotationeller Semantik entstanden sind.
Zusammenfassung
Besser als den Karren ständig aus dem Dreck zu ziehen, ist es, ihn gar nicht erst in den Dreck zu fahren. Wir haben in diesem Artikel gezeigt, wie das in der Softwarearchitektur gelingt. Die Werkseinstellung – der Default – zeigt bei funktionalen Programmiersprachen bereits in Richtung loser Kopplung, insbesondere beim Umgang mit Daten und beim Umgang mit Effekten. Darüber hinaus bietet die funktionale Softwarearchitektur mit „Make Illegal States Unrepresentable“ und der denotationellen Semantik zwei fortgeschrittene Techniken, mit deren Anwendung man ganz beiläufig bei lose gekoppelten und damit modularen Programmen landet.
Den Code aus diesem Artikel gibt es auf GitHub [5].
Links & Literatur
[1] International Software Architecture Qualification Board: https://www.isaqb.org
[2] Software Engineering Institute: https://www.sei.cmu.edu
[3] Typelevel Cats: https://typelevel.org/cats
[4] Functional Core, Imperative Shell: http://functional-architecture.org/functional_core_imperative_shell
[5] Code zum Artikel: https://github.com/active-group/javamagazin-serie-funktionale-programmierung