Funktionale API
December 14, 2022
typescript
Die meisten Programmiersprachen sind Objektorientiert. Klassen sind deshalb das am weit verbreiteste Konzept unter den Programmiersprachen. Bei diesem Konzept werden Daten und Operationen eng aneinander gebunden und durch Mutationen verändert — Eine Abstraktion die sich in der Praxis häufig schräg anfühlt. In der funktionalen Programmierung sind nicht Klassen das Hauptkonzept, sondern Funktionen. Doch was unterscheidet die funktionale Programmierung von der Objekt-Orientierten und wie sieht eine Funktionale Modellierung aus?
Klassen verbinden Daten und Operationen. Dabei wird impliziert, dass Daten und Operationen eine 1 zu 1 Beziehung haben. Das ist häufig definitiv der Fall, wird aber dann zum Problem, wenn es nichtmehr passt.
Code sagt mehr als 1000 Worte — hier ein Beispiel. Eine Applikation verwaltet Personen.
- Personen haben dabei einen Nachnamen und Kinder.
- Personen können einander heiraten.
- Bei einer Heirat, wird der Name der Personen geändert, und deren Kinder umbenannt.
- Beim Heiraten gibt es zusätzlich noch einen Standesbeamten, der die Heirat beurkundet.
Eine Modellierung mit Klassen könnte dabei so aussehen:
class Person {
name: String;
children: Person[]
getName() {
return name;
}
setName(name: String) {
if(name === null) {
throw new Error();
}
this.name = name;
}
marry(p: Person, beamter: Official) {
p.setName(this.name)
for(const child of p.children) {
child.name = this.name;
}
beamter.addMarriage(this, p)
// ...
}
}
Dabei fällt auf, das die Person die Hoheit der Daten inne hält.
Im Falle von marry
bedeutet das, dass die Daten über eine Instanz der Person person.marry
geändert werden.
Zusätzlich werden Attribute/Daten durch “getter und setter” durch eine Schnittstelle nach außen öffentlich gemacht.
Das ist dann Problematisch, wenn der Besitzer der Operation nicht eindeutig ist.
Warum ist es beispielsweise person.marry(p2, beamter)
warum nicht beamter.marry(p1, p2)
oder gar
new Marriage(p1, p2, beamter)
.
Diese Frage stellt sich, da es bei der Klassenorientierten Programmierung eben genau dieses Konzept von Daten+Operationen mit einer 1 zu 1 Beziehung gibt.
Aus diesem Grund wird in der Praxis dann das Konzept eines “Services” eingeführt und das, was einst Objekte waren, werden zu einfachen Datenhaltern mit gettern und settern degradiert.
class MarriageService {
marry(p: Person, p2: Person, beamter: Beamter) {
// some logic using
p.getName()
p2.getName()
beamter.getName()
// ...
}
}
Die Logik selbst lebt dann in den Services.
In der Funktionalen Programmierung gibt es das nicht
Doch wie sieht das in der Funktionalen Programmierung aus? In der Klassenbasierten Programmierung wird eine Operation durch eine dot-Notation an ein Objekte gebunden.
person .marry (p1, beamter)
^ ^ ^
Subjekt Operation Parameter
Das Subjekt (der Besitzer) wird hier zum Dreh- und Angelpunkt der Modellierung.
Bei der Funktionalen Programmierung lösen wir diese Beziehung auf: Die Daten-Operations-Beziehung wird weggenommen und das Konzept eines “Subjektes” weggelassen. Stattdessen sind Parameter als Mittel der Wahl.
marry (p1, p2, beamter)
^ ^
Operation Parameter
Auf der ersten Blick sieht das aus wie alter prozeduraler Code — ist er aber nicht. Denn bei der funktionalen Programmierung gelten zusätzliche Spielregeln:
- Jede Funktion/Operation hat einen Wiedergabewert
- Operationen mutieren Daten niemals in-place, sondern erstellen Kopien. (Wichtiger Unterschied zur prozeduralen Programmierung)
- Funktionen erhalten ihre Abhängigkeiten (Daten) über Parameter (d.h. globale Kontexte gibt es nicht)
- Funktionen sind Kontextlos
Umgesetzt könnte eine gleichwertige funktionale Modellierung so aussehen:
type Person = { name: string }
const Person = (name: string) => ({name})
const getName = (p: Person) => p.name;
const setName = (name: string, p: Person): Person|null => {
if(name === null) return null;
return {...p, name }
}
const marry = (p: Person, p2: Person, beamter: Beamter): [Person, Person] => [
{...p, name: p.name},
{...p2, name: p.name},
{...beamter, marriedPersons: [...beamter.marriedPersons, p, p2]}
]
Werden jetzt beide Modellierung in ihrer Nutzung gegenüber gestellt ergibt sich folgende Schnittstelle in der Benutzung
// OOP
Person p1 = new Person("foo");
Person p2 = new Person("bar");
Beamter b = new Beamter();
p1.marry(p2, b);
console.log(p1, p2, b);
----------------------------
// Funktional
const p1 = Person("foo");
const p2 = Person("bar");
const beamter = Beamter();
const [marriedp1, marriedp2, addedMarriageBeamter] = marry(p1, p2, beamter);
// ich habe komplette Kontrolle über den Datenfluss
console.log(marriedp1, p1);
console.log(marriedp2, p2);
console.log(beamter, addedMarriageBeamter);
Was ist jetzt der große Unterschied?
Auf den ersten Blick sehen die Modellierungen sehr ähnlich aus.
Was aber auffällt ist, dass der Datenfluss in der OOP Variante aus Nutzersicht kaum zu erkennen ist.
Weil marry
intern Attribute der Objekte verändert, ist nicht nachvollziehbar,
welchen Zustand die Applikation innerhalb der Ausführung der Methode eigentlich hat — Implizite Mutationen sind die API des Programmierers.
Anders sieht es bei der Funktionalen Modellierung aus: Durch ein Einhalten der Spielregeln ist als Benutzer ganz genau klar was passiert. Die API ist explizit. Ich als Nutzer der API bestimme, was mit den Ergebnissen der Operation passiert. Die API (marry) selbst lässt meine Daten unangeteastet. Das ist das, was ich als verständlich ansehe.
Fazit
Natürlich können beide Ansätze im Tandem verwendet werden. Was wir an der Funktionalen Programmierung ganz klar besser gefällt ist, dass Funktionen der Building-Block der Modellierung sind. Operationen sind deshalb nichtmehr an einzelne Daten gekoppelt. Das führt dazu, dass sich Operationen über verschiedene Datentypen hinweg besser modellieren lassen.
Durch die Spielregeln der Funktionalen Programmierung, speziell Immutability und pure-Functions, wird außerdem sichergestellt, das aus der Ebene des User-Codes nichts unerwartetes mit meinen Daten passiert. Und das ist viel wert.