Für mehr Freizeit: Funktionale Programmierung
Ein Paradigma für wartungsarmen Code
Von: Katja Potensky
Was ist das nochmal?
Die funktionale Programmierung (FP) ist ein Programmierparadigma, also ein Gedankenmodell, wie etwa auch die objektorientierte Programmierung oder die imperative Programmierung. An ihren Rändern ist die FP ebenso schwer abzugrenzen wie jedes andere Paradigma, im Kern dreht sie sich jedoch um – Überraschung – Funktionen.
Warum ein anderes Paradigma?
Eine durchaus berechtigte Frage. Wenn man ein funktionierendes Paradigma hat, warum sollte man sich Gedanken über ein anderes machen? Es gibt viele gute Gründe, die für die FP sprechen. Aber meiner Meinung nach geht es vor allem um eines: Freizeit.
Diese Antwort mag dich vielleicht überraschen, aber lass mich den Punkt weiter ausführen. Obwohl ich das Programmieren liebe – seit über zehn Jahren verbringe ich im Schnitt 80 Stunden pro Woche damit – gibt es zweifelsohne „langweilige“ und „nervige“ Aspekte. Diese treten insbesondere dann häufig auf, wenn ich für das Programmieren bezahlt werde. Das interessante hierbei ist, dass es üblicherweise nicht an der Tätigkeit selbst liegt, sondern daran, wie ich diese Tätigkeit ausführen muss: auf ineffiziente Art und Weise. Ineffizientes Arbeiten führt zu Frust, Frust führt zu Fehlern und Fehler halten mich davon ab, guten Gewissens nach Hause zu gehen und meinen Feierabend zu genießen.
Darum interessiert mich die funktionale Programmierung: Mit ihr kann ich Fehler eher ausschließen und mir läuft nicht um 23:59 Uhr ein kalter Schauer über den Rücken, weil mir ein potenzieller Bug in meinem Code von vor drei Wochen plötzlich in den Kopf schießt.
Monads, Kleisli, Category Theory… wer soll das denn verstehen?
Funktionale Programmierung ist oft mit hochtrabenden Worten und Mathematik behaftet, was abschreckend wirken kann und davon ablenkt, dass FP im Kern wirklich sehr einfach ist.
Aber gut, das ist Addition auch: 1+1=2
. Also warum sieht so kompliziert aus? Die einfache Antwort ist: Weil es zum einen sehr viel mächtiger ist und zum anderen Wissen voraussetzt, das nicht so weit verbreitet ist wie die Addition.
Betrachte es mal von der anderen Seite: Würdest du dich in ein Spaceshuttle setzen, das so aussieht?
@Flyable
class Spaceshuttle {
@Button(0x0123f)
void start() {
system.out.println("whoooosh"); // or whatever sound starting boosters make
}
}
Mächtige Konstrukte haben nun mal eine intrinsische Komplexität. Das heißt aber nicht, dass das erste Projekt gleich ein Spaceshuttle sein muss. Fangen wir also klein an.
Side Effects
Wenn wir im Kontext von FP von Funktionen sprechen, sind hier typischerweise nicht jene Dinge gemeint, die du vielleicht noch aus den Java-Lektionen von früher im Kopf hast. Die funktionale Programmierung legt einen starken Fokus auf sogenannte Side Effects, also Abläufe, die sich nicht direkt im Code der jeweiligen Funktion widerspiegeln. Kleines Beispiel gefällig?
class Person {
private String name;
public void setName(String name) {
this.name = name; // <-- side effect
}
}
Hier setzen wir den Namen eines Person
Objekts um – wir „mutieren“ ihn. Der Side Effect ist, dass der Name, den wir ändern, nicht im Scope von setName
definiert ist. Wir verlassen also ganz nebenbei unseren Scope und beeinflussen Dinge außerhalb unseres direkten Kontexts.
Das kann zum Beispiel dann zu einem Problem werden, wenn initial der Header einer Applikation rechts oben den Namen gerendert hat, anschließend eine Änderung durchgeführt wurde und ein anderer Teil der Applikation den neuen Namen anzeigt. Solche Dinge versuchen wir mit FP zu vermeiden.
Tipp: Wenn du schon mal mit rxjs
, der Java Streams API oder LINQ gearbeitet hast, hast du funktional gearbeitet und dabei höchstwahrscheinlich intuitiv Side Effects vermieden.
Das Problem hierbei ist, dass jedes Programm Side Effects braucht, um nützlich zu sein. Eine Zeile auf die Konsole loggen? Das ist ein Side Effect. Einen Tastendruck registrieren? Side Effect. Das aktuelle Datum anzeigen? Bingo, Side Effect.
FP möchte Side Effects deshalb nicht grundsätzlich vermeiden, sondern sie so gut verwalten, dass keine negativen Konsequenzen unbeabsichtigt in unser Programm rutschen, um dann drei Monate später von unseren Usern zufällig entdeckt zu werden.
Wenn wir Side Effects komplett vermeiden, landen wir bei einem weiteren Konzept: den Pure Functions.
Pure Functions
Im Kontext von FP meinen wir mit Pure Functions all jene Funktionen, die keinen „äußeren Kontext“ verwenden und damit keine Side Effects haben. Ändern wir also unser kleines Beispiel von vorher:
class Person {
private String name;
public static void setName(Person on, String name) {
on.name = name;
}
}
Theoretisch ist unsere setName
Funktion damit pur. Aber das ursprüngliche Problem, nämlich dass Mutationen schwer nachzuvollziehen sind, ist damit nicht gelöst. Hierbei wird uns aber ein weiteres Konzept helfen: die Immutability.
Immutability
Die „Unveränderbarkeit“, also die Unmöglichkeit etwas zu ändern, ist ein weiteres oft verwendetes Konzept in FP. Genau wie bei Side Effects ist das Ziel nicht, Mutationen generell zu verbieten, sondern vorsichtig mit ihnen umzugehen. Wenn wir etwas verändern wollen, sollten wir statt einer Mutation in der Regel besser eine Kopie der bestehenden Instanz erstellen und dort den veränderten Wert einsetzen.
class Person {
public String name;
public static Person setName(Person on, String name) {
Person copy = new Person();
copy.name = name;
return copy;
}
}
Somit können wir jetzt sehr einfach feststellen, ob es Änderungen gegeben hat: Wenn die Referenz sich geändert hat, gab es eine Änderung. Diese doch sehr grundlegende Änderung hat einige Implikationen, die ich genauer beleuchten möchte.
Identität
Die Frage danach, was ein Objekt eindeutig identifiziert, hat sich damit quasi auf den Kopf gestellt. Die Referenz kann nicht mehr dafür verwendet werden, denn dieselbe Person „Katja“ kann ja einmal nur „Katja“ heißen und ein andermal „Katja Potensky“. Stattdessen (und eigentlich auch schon vorher) empfiehlt es sich, eine dedizierte ID zu verwenden.
Performance
Ständig neue Objekte erstellen ist langsamer als bestehende zu mutieren. Aber stell dir die Frage, ob der Sinn deines Programmes wirklich ist, Objekte nur zu verändern, oder ob du die jeweilige Änderung auch irgendwo erkennen willst. In vielen heute typischen Situationen ist es sehr viel teurer, Objekte zu mutieren und nachher zu prüfen, ob sich etwas geändert hat, als einmal ein neues Objekt zu erstellen und nachher per Referenz prüfen zu können, ob eine Änderung stattgefunden hat. Davon abgesehen lassen sich solche Änderungen je nach Sprache sehr gut vom Compiler optimieren.
Nicht jede Sprache ist gleich gut geeignet
Man kann sich leicht vorstellen, dass diese „Änderungsoperationen“ sehr schnell sehr viel Boilerplate Code erzeugen. Zum Glück haben die meisten Sprachen das erkannt und bieten Konstrukte an, um dieses Problem zu lösen. Hier ist der äquivalente Code in JavaScript:
class Person {
name: string;
static setName(on: Person, name: string): Person {
return {...on, name}
}
}
Aber halt, das ist doch nicht ganz das Gleiche? Im Java-Beispiel erstellen wir eine neue Instanz der Klasse Person
, hier erstellen wir nur ein Plain Old JavaScript Object? Stimmt, und das bringt mich zum nächsten Punkt.
„Data and Behavior should be separate“
Die wohl größte Barriere in der Adoption von FP aus Sicht eines klassischen OOP-Entwicklers ist, dass die objektorientierte Programmierung Daten und Verhalten in einer Klasse kapselt. Die funktionale Programmierung hingegen trennt diese beiden Dinge meistens recht strikt. Dieses Thema alleine ist so vielschichtig, dass man einen ganzen Artikel darüber schreiben könnte – aber ich versuche es auf die wichtigsten Punkte herunterzubrechen.
Im aktuellen Beispiel ist das einzige Verhalten schon nicht mehr an die Instanz gebunden (static
) – es könnte genauso außerhalb liegen:
class Person {
name: string;
}
const setName = (on: Person, name: string): Person => ({...on, name});
An diesem Punkt haben wir etwas, das zwar einen Wert zur Laufzeit darstellt – Klassen sind mehr als nur eine Information für den Compiler – aber keinen Wert für die Laufzeit hat. Die „Klasse“ Person
beschreibt nur wie eine Person aussieht und könnte genauso gut ein Type/Interface sein:
type Person = {
name: string;
}
Die Daten so zu modellieren hat vielerlei Vorteile. Zum Beispiel ist es leichter, neue Typen aus bestehenden zu erstellen (Composition), da z.B. die Gefahren von Vererbung gar nicht erst aufkommen. Mutationen an Objekten sind typischerweise auch nicht mehr so problematisch, da jeder weitere Schritt entweder genau die mutierten Daten verwendet oder seine eigene Version der Daten erstellt und diese weiter bearbeitet, anstatt integriertes Verhalten der Kopie zu verwenden. Vorherige Schritte können meist auch nicht mehr so einfach beeinflusst werden, da die potenziell mutierten Objekte nur eine sehr kurze Lebenszeit in einem sehr genau abgegrenzten Bereich haben.
Zusammenfassung
Ich hoffe, ich konnte dir zeigen…
- wie wenig Code es braucht, um Dinge zu erreichen, die du vorher nur mit einem schwergewichtigen Framework für möglich gehalten hast.
- wie klar die Quellen von Fehlern sein werden.
- wie wenige Fehler du überhaupt gemacht haben wirst, weil durch die Anwendung dieser Konzepte der Compiler schon sehr viele Fehler erkennen kann, bevor sie überhaupt das erste mal zum Problem werden.
- dass der Kern der funktionalen Programmierung weit weniger komplex ist als es oft den Anschein hat.
Über die Autorin: Katja Potensky
Katja ist Software Engineer bei adesso und seit 2012 in einem professionellen Setting tätig. Sie lässt sich nicht auf einzelne Aspekte eines Systems limitieren, und hat von daher schon eine Vielzahl an Projekten in unterschiedlichsten Rollen und Teamgrößen umgesetzt. Aus der Arbeit mit unterschiedlichsten Webtechnologien hat sich ein fundierter Anspruch an Codequalität und korrektes Programmverhalten entwickelt den sie auch weitergeben möchte. Sie brennt für Code der leicht zu lesen ist und nicht bedingt die halbe Codebase im Kopf zu behalten um zu verstehen was “da gerade passiert”.