FP einmal anders: Daten, Berechnungen und Aktionen

Nein, das ist kein Bild von Gipf-Oberfrick sondern...
Beitrag erstellt am: 30.06.21
Mittlerweile nutze ich funktionale Programmierung (FP) seit mehreren Jahren mit zunehmender Begeisterung. Dabei stelle ich wiederholt fest, dass ich zahlreiche Ideen und Konzepte der FP schon sehr lange nutze, teilweise ohne dass mir der funktionale Jargon bekannt war. Ich möchte heute auch nicht über die vielen, manchmal durchaus einschüchternden Begriffe der FP schreiben, sondern vielmehr ein paar Ideen präsentieren, die mir bei der Konzeption meiner Systeme nützlich sind und den Schritt in die funktionale Welt leichter machen. Und nein, heute dreht es sich für einmal nicht um das Thema Modulbildung mit Software-Kategorien, versprochen ;-)

Daten und Funktionen

Einen niederschwelligen Einstieg in FP bietet die grundsätzliche Unterscheidung zwischen Daten und Funktionen. Daten sind idealerweise unveränderlich und Funktionen haben keine Seiteneffekte, sprich ihre Ausführung ändert den observierbaren Zustand des Systems nicht.

Wer sich schon mit FP beschäftigt hat, weiss: Das Spektrum an Konzepten und Möglichkeiten von FP geht weit über diese einfache Unterscheidung hinaus. Sie ist jedoch als Einstieg ins FP-Land, in dem Funktionen und nicht klassenbasierte Objekte die primäre Einheit der Abstraktion sind, ein guter Ausgangspunkt, wie ich hoffentlich zeigen kann.

Einer der Knackpunkte, der gerade Entwicklern, die mit OO und imperativer Programmierung vertraut sind, Kopfzerbrechen bereitet ist die Sache mit den Seiteneffekten: Wie baut man mit Funktionen, die letztlich nur rechnen, in der FP sprechen wir von puren bzw. reinen Funktionen, ein sinnvolles System?

Entlang dieser Frage möchte ich einige einfache Ideen beschreiben, die den Einstieg in die funktionale Welt nach meiner Erfahrung leichter machen. Wir kommen dabei ganz ohne Konzepte wie Partial Application, Currying, Functor, Applicative, Monoid, Monad etc. aus. Mein Bestreben ist es, sozusagen auf vertrautem Terrain einzusteigen und damit die Basis für weitere Erkundungen im FP-Land zu ermöglichen.

Seiteneffekt - ein etwas irreführender Begriff

Bevor wir loslegen, noch ein paar Worte zum Begriff "Seiteneffekt", engl. Side Effect: Er hat sich in der funktionalen Programmierung als Begriff für Zustandsänderungen eingebürgert; er bezeichnet eine beabsichtige Wirkung und nicht einen unerwünschten Effekt, vgl. hierzu u.a. den folgenden Wikipedia-Beitrag Wirkung.

Es geht somit nicht darum, solche Zustandsänderungen vollständig zu vermeiden, sondern sie in geordnete Bahnen zu lenken, um die erwünschten Wirkungen besser steuern zu können. Dieses Ziel hat sowohl die OO als auch die FP - beide Paradigmen erkennen geteilten und veränderlichen Status als schwieriges Problem:
  • In der OO nutzt man Kapselung, um mögliche Zustandsänderungen logisch zu gruppieren und so besser beherrschen zu können.
  • In der FP minimiert man Zustandsänderungen, wo immer möglich, und drängt sie so weit als möglich an den Rand der Systeme. Ein Weg dazu, ist zu beschreiben, was zu tun ist, anstatt es tatsächlich zu tun. Keine Sorge, das werden wir uns gleich näher ansehen.
Wir verfeinern die eingangs beschriebene Unterscheidung von Daten und Funktionen zu Daten, Berechnungen und Aktionen:
  • unveränderliche Daten: sie können beliebig gelesen und gefahrlos geteilt werden, da sie nie ändern. Wertänderungen implementieren wir mittels Copy-on-Write.
  • seiteneffektfreie Berechnungen (pure Funktionen): sie können beliebig oft ausgeführt werden, das Ergebnis ist vollständig von den Argumenten der Berechnung abhängig. Der Zeitpunkt der Ausführung ist dabei für das Ergebnis irrelevant; für gleiche Argumente lässt sich das Resultat eines Funktionsaufrufs bei Bedarf gefahrlos zwischenspeichern. 
  • Aktionen: sie lösen Seiteneffekte aus. Der Zeitpunkt der Ausführung ist hierbei relevant. Ebenso, wie oft die Aktion ausgeführt wird: es macht einen Unterschied, ob z.B. das Belasten einer Kreditkarte oder das Versenden einer E-Mail einmal oder mehrmals ausgeführt wird.
Wie bereits erwähnt, nutzen wir in der FP, wenn immer möglich, Daten und Berechnungen. Aktionen sind zwar nötig, sollen jedoch, soweit es geht, vermieden werden und nur an den Rändern unseres Systems vorkommen:
Funktionale Systemstruktur

Von imperativ zu deklarativ

Seiteneffekte und damit Aktionen sind allgegenwärtig. Von der imperativen Programmierung sind wir es gewohnt, dass z.B. die Methoden einer Klasse etwas tun, sprich den Status eines Objekts ändern, beispielsweise die Bestellmenge eines Artikels in einem Warenkorb erhöhen, die E-Mail-Adresse eines Benutzers ändern, eine neue Nachricht in eine Liste eintragen, etc.

Die Frage lautet nun: Wie können wir solche imperativen Implementierungen in Berechnungen verwandeln? Und wie sorgen wir dann dafür, dass eine solche Berechnung auch den gewünschten Seiteneffekt auslöst?

Schritt 1: Daten sind unveränderlich

Alle Daten sind unveränderlich. Möchten wir daher einen Datenwert ändern, kopieren wir die ursprüngliche Datenstruktur, auf dem die Werteänderung angewandt werden soll, und setzen den neuen Wert (Copy-on-Write); die ursprünglichen Daten werden dabei nicht verändert.

Das Ändern der E-Mail-Adresse eines Benutzers implementieren wir also beispielsweise, indem wir eine Kopie des ursprünglichen Objekts erstellen und die neue Mail-Adresse setzen. Es ist dabei egal, ob wir die benötigte Datenstruktur mittels einer Benutzer-Klasse oder einem leichtgewichtigeren Struct oder Datenobjekt umsetzen.
Copy-on-Write

Schritt 2: Anweisungen los werden

Copy-on-Write ist eine Berechnung

Vereinfacht können wir uns Copy-on-Write als Funktion vorstellen, die in der Lage ist, aufgrund einer vorhandenen Datenstruktur, im Beispiel die Benutzerdaten, und der gewünschten Datenänderung, die geänderte E-Mail-Adresse, die neue Datenstruktur zu berechnen. Ja, sie haben richtig gelesen: berechnen! Das Ändern des Datenwerts ist so zu einer seiteneffektfreien Funktion geworden, das Ergebnis hängt einzig von den übergebenen Argumenten ab!
Die Copy-on-Write-Lösung ist im Umgang mit Daten die einfachste Art, imperativen seiteneffektbehafteten Code in eine Berechnung zu verwandeln. Der Trick funktioniert im Grundsatz in einer Vielzahl von Situationen und lässt sich einfach zusammenfassen:
0100-lamp1
 Mache aus einer Anweisung eine Frage.

Das klappt auch mit Collections...

Mit diesem Kniff können wir zahlreiche Anweisungen in Berechnungen verwandeln. Im Falle der Änderung der E-Mail-Adresse lautet die Frage: Wie sehen die Benutzerdaten aus, wenn wir die E-Mail-Adresse ändern?

Auch für eine Liste oder jede andere Form von Collection hilft uns diese Frage beispielsweise um das Hinzufügen oder das Löschen eines Elements als Berechnung zu betrachten. Die Frage lautet: Wie sieht die Liste X aus, wenn ich Element E hinzufüge bzw. entferne? Eine passende Funktion nimmt die Liste und das Element und gibt eine Liste zurück, die das gewünschte Element enthält bzw. aus der das Element entfernt wurde. Die ursprüngliche Liste wird dabei nicht verändert. Die Funktion ist eine Berechnung, die Daten als Input erhält und Daten als Output liefert.

... und in vielen anderen Situationen

Alleine mit dieser Herangehensweise können wir eine Vielzahl von Funktionen, die Seiteneffekte aufweisen, in Berechnungen verwandeln. Ich hoffe, es ist offensichtlich, dass sich dadurch auch das Testen bereits deutlich vereinfacht.

Doch es gibt auch Seiteneffekte, die nicht ohne Weiteres durch eine Berechnung ersetzt werden können, weil wir tatsächlich etwas tun müssen, beispielsweise das Versenden einer E-Mail, das Speichern von Daten in einer Datenbank, das Ausführen einer Kreditkartentransaktion. Zumindest scheint es auf den ersten Blick so, dass wir diese Art von Seiteneffekten tatsächlich anders behandeln müssen.

Schritt 3: Sagen, was zu tun ist, anstatt es zu tun

Imperativer Code tut etwas, er ändert Daten auf vielfältige Art, er führt Aktionen aus. Deklarativer Code beschreibt dagegen, was getan werden soll; er berechnet quasi die Seiteneffekte, führt sie aber nicht aus. Diese Aufgabe übernehmen die Aktionen an den Systemrändern.

Es geht also auch hier darum, aus einer Anweisung eine Frage zu machen, die Berechnung des Seiteneffekts. Oder nochmals anders formuliert: die Berechnung liefert als Ergebnis eine Beschreibung davon, was getan werden muss. Eine passende Aktion nimmt diese Beschreibung und wendet sie an.

Damit eine Aktion etwas tun kann, benötigt auch sie Daten, z.B. die Kreditkartendaten, den Betrag und die Währung für die Abwicklung der Transaktion. Diesbezüglich unterscheidet sich eine Aktion noch nicht von einer Berechnung. Was definitiv anders ist: die Aktion tut etwas, was nach Aussen eine Wirkung hat. Und wie ich einführend festgehalten habe, spielt es eine Rolle, ob die Aktion gar nicht, einmal oder mehrmals und vor allem auch wann sie ausgeführt wird.

Ausgestattet mit diesem Wissen, können wir daran gehen, das Belasten der Kreditkarte in eine Berechnung und eine Aktion aufzuteilen:

Nehmen wir an, wir haben eine einfache Datenstruktur für die Kreditkarte, im Beispiel eine simple Dart-Klasse:
class Card {
  final CreditCardNumber cardNumber;
  final ExpirationDate expiresOn;
  final Name nameOnCard;
  Card(this.cardNumber, this.expiresOn, this.nameOnCard);
}
Die Eigenschaften CardNumber, ExpirationDate und Name sind als Domain Primitives umgesetzt.

Weiter gibt es eine seiteneffektbehaftete Funktion, die mittels PaymentProvider eine Kredikartenbelastung abwickeln kann:
 // Provider-Schnittstelle für das Anstossen eines Zahlungsdienstleisters
abstract class PaymentProvider {
  Result execute(CreditCardNumber ccn, ExpirationDate expiresOn, Amount amount);
}

// Funktion mit Seiteneffekt = Aktion
Result debit(PaymentProvider provider, Card card, Amount amount) {
  return provider.execute(card.cardNumber, card.expiresOn, amount);
}
Diese Funktion ist stark vereinfacht und entspricht keiner realen Implementierung. Es geht mir darum zu zeigen, wie wir die imperative Lösung aufteilen in:
  • eine Berechnung des auszuführenden Seiteneffekts
  • und die Aktion, die ihn ausführt.
Haben wir diese Prinzip verstanden, können wir unseren imperativen Code Stück für Stück in eine deklarative Lösung überführen, die deutlich einfacher zu testen ist als die imperative Variante.

Und das geht so:

Wir legen uns eine Datenstruktur an, die die auszuführende Transaktion beschreibt, der Code ist wiederum beispielhaft und entsprechend vereinfacht:
class DebitTransaction {
  final Card card;
  final Amount amount;
 
  DebitTransaction(this.card, this.amount);
}
Nun brauchen wir eine simple Funktion, die uns die passenden Daten für die Kreditkartenbuchung liefert:
// Simple Berechnung des benötigten Transaktions-Objekts
DebitTransaction debitOnCard(Card card, Amount amount) => DebitTransaction(card, amount);
Das retournierte DebitTransaction-Objekt enthält alle Informationen, die nötig sind, um die Kartenbelastung durchzuführen.

Nun können wir die ursprüngliche Aktion so umschreiben, dass sie die DebitTransaction als Basis für die durchzuführende Buchung verwendet:
// Action, welche die Transaktion tatsächlich ausführt, nutzt einen PaymentProvider
Result executeTransaction(PaymentProvider provider, DebitTransaction trsx) {
  return provider.charge(trsx.card.cardNumber, trsx.card.expiresOn, trsx.amount);
}
Die Berechnung der Transaktionsdaten und ihre Verarbeitung sind nun getrennt:
Berechnungen und Aktionen am Beispiel debitOnCard
Was jetzt noch fehlt, ist die Verknüpfung der Berechnung mit der Aktion. Am einfachsten und ganz direkt so:
Result debit(PaymentProvider provider, Card card, Amount amount) {
  return executeTransaction(provider, debitOnCard(card, amount));
}

Reaktiv wär's besser

Die Funktion debit bleibt eine Aktion und ist damit idealerweise nicht Teil unseres Anwendungskerns, der sollte ja eben frei von Seiteneffekten sein. Und damit stellt sich die Frage, wo der tatsächliche Aufruf dieser Funktion erfolgen soll.

Elegant geht das, wenn wir die DebitTransaction als Ereignis betrachten, das wir auf einem Ereignis-Bus publizieren. Damit lässt sich mittels Subskription von aussen auf das Ereignis reagieren und die nötige Verarbeitung auslösen. Diese Idee werde ich in einem weiteren Beitrag zum Thema FP-basiertes Event Modeling vertiefen.

Da fehlt doch was!

Ich habe es zu Beginn dieses Beitrages bereits erwähnt: die vorgestellten Ideen haben auf den ersten Blick nur wenig mit dem zu tun, was man üblicherweise als Einführung zum Thema Funktionale Programmierung liest.

Wer versteht, wie unveränderliche Daten, seiteneffektfreie Berechnungen und Aktionen im Umgang mit Seiteneffekten helfen, dem wird sich Sinn und Zweck vieler funktionaler Konzepte deutlich leichter erschliessen.