Einfach, sicher, produktiv: Funktionale Programmierung

So lesbar könnte und sollte sicherer Code aussehen:
Wenn Ihr Code so klar und ausdrucksstark ist, haben Sie diesen Beitrag womöglich nicht mehr nötig. Alle anderen sollten sich von seiner Länge nicht abschrecken lassen, denn: Ich möchte Ihnen anhand konkreter Codebeispiele die Grundprinzipien und immensen Potenziale der sogenannten funktionalen Programmierung (FP) vermitteln. Am Ende werden Sie nicht nur glauben, dass die oben gezeigte Codestrecke funktioniert, sondern auch schon ungefähr verstehen, wie und warum.
Bitte nehmen Sie sich die Zeit zur Lektüre also in Ihrem eigenen Interesse! Der Einsatz von FP kann Ihre Entwicklung auf ein völlig neues Niveau von Qualität und Produktivität heben. Das sollte Entwickler genauso interessieren wie Manager.
Ich werde am Anfang zunächst kurz auf die Motivation und den theoretischen Hintergrund von FP eingehen. Das soll dem Verständnis dienen, ist aber für die weiter unten folgenden Erläuterungen anhand konkreter Codebeispiele nicht unbedingt nötig. Sie können auch direkt zu „Unsere ersten Funktionen“ springen und ggf. bei Fragen auf die Einleitung zurückkommen.
Scheitern scheint vorprogrammiert zu sein
Programmierer stehen in einem eher schlechten Ruf. Fast jeder kennt Symptome wie diese:
- Hoher Anteil von Debugging und/oder Defekten
- Regression der Software (d.h. Defekte durch Wartung)
- Entwickler verstehen nach kurzer Zeit den eigenen Code nicht mehr
- Entwickler schreiben lieber neuen Code, statt existierenden zu verwenden
- Abnehmende Wartbarkeit bis hin zum Wartungskollaps
- „Isolierte Programmsituationen“ (Euphemismus für unerklärliche Fehler)
- Produktivität und Qualität suboptimal, Ziele werden gerissen
Das alles weist auf handwerkliche Schwächen hin. Wer (ob als Manager oder Entwickler) diese Symptome ernst nimmt, muss daran interessiert sein, die Entwicklung einfacher, sicherer und produktiver zu machen. Man kann und sollte mit sinnvollen Prozessen (Agile, TDD, BDD, u.ä) gute Rahmenbedingungen schaffen. Aber für das Vermeiden handwerklicher Fehler gibt es m.E. keinen größeren Schritt als die Einführung des Paradigma „Funktionale Programmierung“.
Viele halten funktionale Programmierung irrtümlich für komplizierter als „normale“ Programmierung. Eigentlich ist eher das Gegenteil richtig: Guter FP-Code besticht durch seine Einfachheit und Klarheit. Davon können Sie sich in diesem Beitrag überzeugen. Richtig verstanden, kann FP den Softwareentwickler von unnötigem mentalen Ballast befreien und damit erheblich die Produktivität und Qualität erhöhen.
Auch vor der Lektüre des Folgenden sollten Sie intuitiv begreifen können, was die eingangs gezeigte Codestrecke errechnet. Und das ist der Punkt: Jeder, dessen Code deutlich komplizierter und/oder unstabiler ist, sollte sich fragen: Muss das im Jahr 2017 noch sein? Und: Warum ist das überhaupt so? Mit dieser Frage möchte ich beginnen.
Problemverursacher: „Wie“ statt „Was“
Die eingangs genannten (und weitere) Probleme sind typische Symptome der sogenannten „imperativen Programmierung“ (zu dieser Familie gehört übrigens auch das in der Industie am meisten verbreitete Paradigma der objekt-orientierten Programmierung!). Diese Art des Programmierens ist seit Jahrzehnten so allgegenwärtig, dass sie zu selten in Frage gestellt wird, obwohl es bessere Alternativen gäbe (neben FP gibt es z.B. auch logische Programmierung, die für manche Aufgabenstellungen eine interessante Alternative ist).
Die meisten Menschen (Profis wie Amateure) haben von Programmierung folgende Vorstellung: Man beschreibt Programmabläufe(Algorithmen), die schrittweise Daten einlesen, verarbeiten/verändern und ausgeben. Genau dieses schrittweise Vorschreiben (Programm: lat. das Vor-Geschriebene) nennt man imperativ (lat. „befehlend“).
Im Kleinen funktioniert das auch noch einigermaßen gut, gerade für Anfänger, weil es an Kochrezepte o.ä. erinnert. Aber das Problem ist: Mit zunehmender Programmgröße wird das ganze unübersichtlich, es kommt immer stärker zu den gefürchteten Nebeneffekten. Selbst die einfachsten Dinge wie die Berechnung eines Auftragswerts aus der Summe seiner Positionen erfordern im Verhältnis zum fachlichen Problem zu viel Aufmerksamkeit. Hochgerechnet auf komplexe Anwendungen führt das zu einer mentalen Überlastung der Programmierer. Die Folge: Kleinste Konzentrationsschwächen oder Störungen führen zur Einführung von Fehlern, deren Folgen oft weit über die aktuell bearbeitete Programmstelle hinausgehen.
Diese ohnehin schon problematische Situation ist in den letzten Jahrzehnten noch verschärft worden: Softwaresyteme werden nicht nur immer komplexer, sondern arbeiten zunehmend nebenläufig und verteilt (man denke nur an Multi-Core-Prozessoren und die asynchrone, verteilte Natur des Internets). Durch die Grundschwäche der imperativen Programmierung, ständig Datenzustände zu verändern, kommt es dabei zu den gefürchteten „race conditions“, bei denen Algorithmen sich gegenseitig in die Quere kommen und inkonsistente Zustände erzeugen. Mit steigender Last tauchen plötzlich vermeintlich unerklärliche Fehler auf – auch bekabnnt als „Heisen-Bugs“ (in Anspielung auf Werner Heisenberg, Erfinder der „Unschärferelation“ der Quantentheorie und einer der theoretischen Väter der Atombombe). Die Programme fangen an, sich nondeterministisch (nicht vorhersagbar) zu verhalten – und das ist ja gerade nicht das Ziel imperativer Programmierung.
Back to Church: Transformieren statt Reformieren
Wenn es um die Grundlagen moderner Programmierung geht, fällt meist der Name Alan Turing. Weniger bekannt ist sein Zeitgenosse Alonzo Church. Dieser entwickelte 1935 das sogenannte Lambda(λ)-Kalkül und legte damit nicht nur den Grundstein für Compilerbau, sondern auch für das, was wir heute funktionale Programmierung nennen.
Schon wenn man die theoretische Modelle dieser beiden vergleicht, ahnt man, welches eher zu Problemen führt: Turing arbeitete u.a. mit der nach ihm benannten Maschine, einem Mechanismus, der theoretisch unendliche Bänder vor und zurückspulen und die auf ihm befindlichen Informationen beliebig immer wieder ändern kann. Selbst die einfachsten Lösungen werden auf dieser hypothetischen Infrastruktur schnell extrem unübersichtlich, der kleinste Fehler verursacht das Scheitern des Programms. Zwar ist die Turing-Maschine nur ein theoretisches Modell zum Beweis des sogenannten Entscheidungsproblems, aber ihre Funktionsweise ist eben auch ein Menetekel für das Scheitern der imperativen Programmierung an Komplexität.
Church hingegen hat sich überlegt, was die einfachste und universellste Weise wäre, Funktionalität so darzustellen, dass man sie einer Maschine beibringen kann. Statt auf mechanische Maschinen a la Turing hat er sich an den Grundbausteinen der Mathematik orientiert: Funktionen. Deren Grundprinzip ist es, erhaltene Parameter in Rückgabewerte zu transformieren, ohne jemals die Parameter selbst zu ändern. Diese Einschränkung hat Church zu einem Modell geführt, das so mächtig wie Turings ist, aber viel einfacher und ungleich sicherer (im Sinne von robuster und korrekter).
Ein wenig Theorie, aber keine Angst vor griechischen Buchstaben!
Auch wenn Mathematik nicht Ihr Ding ist, können Sie sorglos weiterlesen: Denn das Lambda-Kalkül von Church besticht durch seine Einfachheit. Es kann nur Funktionen beschreiben, die einen einzigen Parameter erhalten und einen Ausdruck zu dessen Umwandlung besitzen!
Schauen wir, wie die Quadratfunktion bei Church aussieht:
Ist doch einfach, oder? λ
definiert eine Funktion, x
ist ihr Parameter, der Punkt trennt Parameter und Transformationsausdruck. Es ist einfach nur eine andere Notation für Funktionen.
Die softwaretechnische Umsetzung ist auch einfach: Nehmen wir an, die o.g. Quadratfunktion hätte den symbolischen Namen sqr
erhalten. Wenn im Programmcode z.B. sqr(x)
auftaucht, dann weiß der Compiler, dass er das durch x*x
ersetzen kann. Das gleiche gilt natürlich, wenn statt x
konstante Werte verwendet werden (sqr(3)
könnte im Compilat sofort als 9 stehen). Nur damit wir das auch erwähnt haben: Diesen Vorgang nennt man im Lambda-Kalkül Beta(β)-Reduktion. Den Begriff werden Sie aber vielleicht nie mehr benutzen. Es sei denn, Sie sind Compilerentwickler – oder Angeber.
Nun wissen wir aber, dass die wenigsten Funktionen nur einem Parameter haben. Und hier zeigt sich wieder einmal, dass Beschränkung frei machen kann. Denn auch Church stand natürlich vor diesem Problem – und er hat es genial einfach gelöst. Schauen Sie sich die Definition einer Additionsfunktion in Lambdanotation an:
Aha, zwei Parameter, zwei Lambdas. Aber was bedeutet das? Das erste Lambda erzeugt ein zweites Lambda, in dessen Transformationsausdruck der Wert des Parameter des ersten auftaucht (man spricht hier auch vom Binden eines Parameters; der Rückgabewert ist eine sogenannte Closure, weil darin der konkrete Wert von x
beim Aufruf von f(x)
eingeschlossen wird)!
Dieser genial einfache Trick führt implizit zu einer weiteren ganz grundlegenden Eigenschaften funktionaler Programmierung: Funktionen selbst sind Typen, beschrieben über ihre Signatur. Das führt zu vielen extrem mächtigen Möglichkeiten, die es so nur in der FP gibt: Funktionen höherer Ordnung, Partial Application, Currying, Piping etc. Keine Sorge, das ist alles ganz einfach, und Sie werden es bald kennen.
Zum Abschluss des Theorieblocks und als Übergang in die Praxis möchte ich Ihnen zeigen, wie (von der syntaktischen Vereinfachung in Programmiersprachen abgesehen) die Signatur (also der Typ) einer FP-Funktion eigentlich typischerweise aussieht, hier am Beispiel der o.g. add
-Funktion, die mit dem Ganzzahltyp int
arbeiten soll:
Eine FP-Funktion mit vermeintlich zwei Parametern ist also in Wirklichkeit eine mit einem Parameter, deren Rückgabewert eine weitere Funktion mit ebenfalls einem Parameter ist!
Damit sei vorerst Schluss mit der Theorie. Was das Lambda-Kalkül in der Praxis für Möglichkeiten bietet und dass es sich mit der richtigen Programmiersprache überhaupt nicht fremd anfühlen muss, möchte ich Ihnen im Folgenden demonstrieren.
Ein Schluck vom Zaubertrank gefällig? Elixir
Als Sprache für unsere Codebeispiele habe ich Elixir verwendet. Zum einen ist sie elegant und auch ohne große Erläuterungen leicht verständlich, zum anderen ist sie als Technologie für Digitalisierung und IoT eine ziemlich gute Wahl.
Wenn Sie gerne hands-on lernen: Ausführliche Infos und gute Start-Tutorials finden Sie unter https://elixir-lang.org/ (die folgenden Codebeispiele können Sie aber auch ohne weiteres beim Lesen nachvollziehen, die Sprache ist einfach genug)

Unsere ersten Funktionen
Dann schreiben wir doch einfach mal Funktionen, wie wir sie oben besprochen haben. In Elixir macht man das in sogenannten Modulen:
Viel eleganter kann eine Sprache nicht mehr sein, oder? Auf den ersten Blick mögen Sie Datentypen vermissen. Eigentlich bin ich auch ein Verfechter eines starken Typsystems, aber Elixir hat mich mit seiner Typlosigkeit bisher überzeugt, zumal ein message-basiertes Aktorsystem wegen der losen Kopplung in der Regel ohnehin nicht mit Typen arbeiten sollte.
Wenn man Funktionen eines Moduls verwenden möchte, kann man das entweder explizit durch Voranstellen des Modulnamens machen (SimpleSample.add
) oder indem man das Modul in den aktuellen Scope importiert. Um die Codebeispiele übersichtlich zu halten, wählen wir für unser Modul diesen zweiten Weg.
Wie alle modernen Sprachen besitzt Elixir eine interaktive Shell (REPL). Dort können wir unsere Funktionen erproben (der Einfachheit halber lasse ich im Folgenden das vorangestellte Prompt iex(…)>
jeweils weg und gebe die Ausgabe als Kommentar an:
Wie kann ich mir Werte „merken“, um sie in weiteren Schritten zu verwenden? So einfach, wie man sich das wünscht:
result
ist in diesem Beispiel übrigens keine Variable, wie Sie sie aus anderen Programmiersprachen kennen, sondern nur ein Alias für einen unveränderlichen Wert. Aus Convenience-Gründen kann man solche Aliasse in Elixir sogar mehrfach verwenden, wodurch es wie eine Variable aussieht. Aber der Compiler schützt uns vor Ungemach und weist hinter den Kulissen jeweils neue eindeutige Namen zu. Ein Beispiel dafür, wie Elixir uns den Umstieg in die FP einfach macht.
Durch diese Röhre muss er kommen: Die Pipe
Aber in FP-Code ist es ohnehin eher unüblich, mit zugewiesenen Zwischenergebnissen zu arbeiten, vor allem wenn man nur am Endergebnis interessiert ist. In Java u.ä. Sprachen hat man als Notlösung dieses Problems das implemetierungsintensive Pattern Fluent API erfunden. In der FP gehört diese Problematik der Weiterverarbeitung zum Grundkonzept „Komponierbarkeit“ (darunter versteht man das Zusammenstecken von Funktionen zu größeren Funktionen – funktionales Lego, sozusagen).
Sie werden dafür schnell ein Ausdrucksmittel verinnerlichen und lieben, das Sie vielleicht aus Unix kennen: Piping
Das fühlt sich schon fast wie ein Taschenrechner an, oder? (Und es gleicht fast imperativem Programmieren durch das Deklarieren einer Transformations-Abfolge, aber auf Basis von Funktionen und ohne „Variablen“.) Genau dieses intuitive und mächtige Aneinanderreihen von Funktionen ist ein allgegenwärtiges Konzept von FP, das übrigens ohne zusätzlichen Entwicklungsaufwand auf allen Abstraktionsebenen gleich funktioniert. Es ist Grundlage des mächtigen Architekturmusters „Pipes & Filters“, wie auch der Code zu Beginn des Beitrags zeigt. Der Elixir-Webserver Phoenix besitzt auf Basis dieses Musters eine beeindruckend stabil und einfach erweiterbare Architektur mit sogenannten Plugs.
Falls jemand Pipes nicht kennt, hier die einfache Arbeitsweise: Der Pipe-Operator (in Unix ist das |
, in Elixir |>
) nimmt den Rückgabewert der vorigen Funktion und speist ihn als ersten Parameter in die folgende Funktion. Das ist auch der Grund, warum im Beispiel die Funktionsaufrufe jeweils einen Parameter weniger benötigen, ohne dass Sie irgendwas anders definieren müssen. An dieser Stelle sei an das Lambda-Kalkül erinnert, das mit seinem einparameterigen Aufbau hierfür die Grundlage ist.
Level up: Funktionen höherer Ordnung
Bereits der Verzicht auf veränderliche Zustände bietet inhärente Stabilitätsvorteile, wäre aber mit einiger Disziplin auch in imperativer Programmierung zu erreichen (in Java kann man bei konsequenter Verwenundg von final
durchaus thread-sicheren Code schreiben, das macht bloß kaum jemand). Die eigentliche Stärke von FP sind aber eine große Ausdrucksmächtigkeit und ein Grad an Wiederverwendung, den sie in OOP selbst beim besten Design nicht erreichen werden. Und das wird vor allem durch ein bei FP unabdingbares Konzept ermöglicht: Funktionen höherer Ordnung. Das sind ganz einfach Funktionen, deren Parameter und/oder Rückgabewert selbst Funktionen sind.
Eingangs habe ich Ihnen erläutert, dass die Einfachheit des Lambda-Kalküls zu einer wichtigen Eigenschaft von FP-Sprachen führte: dass Funktionen selbst Typen sind. Das entspricht auch mathematischem Usus und Bedarf. Denken Sie z. B. an die Mengenlehre.
Worum geht es bei Funktionen höherer Ordnung? Nicht nur in der Mathematik, sondern auch in Anwendungen des alltäglichen Gebrauchs hat man regelmäßig den Bedarf, Datenmengen umzuwandeln, zu filtern, zu sortieren, zu kumulieren u.ä. In der imperativen Programmierung macht man so etwas eher umständlich mit Schleifen, Variablen etc. Durch diesen Aufwand sind über die Jahrzehnte vermutlich Millionen Personenjahre vergeudet worden. Das Bewusstsein für die unsinnig hohe Redundanz und Fehleranfälligkeit dieses Ansatzes hat z. B. zu OO-Patterns wie „Template Method“, „Strategy“ u.ä. geführt. Aber diese Muster sind Behelfslösungen und kurieren eigentlich nur Symptome. Die Diagnose bleibt: Imperative Programmierung hält sich zu sehr mit dem „Wie“ auf. Funktionale Programmierung kümmert sich um das „Was“, ist also eher deklarativ.
Ich erläutere das am klassischen Beispiel der Mengenlehre: Der Abbildung einer Menge auf eine andere. Wer im Mathematikunterricht nicht geschlafen hat, weiß, dass man dazu eine Abbildungsfunktion benötigt, die jeweils ein Element der Quellmenge in ein Element der Zielmenge transformiert.
Schauen wir uns im Code an, wie wir für die Menge der natürlichen Zahlen 1 bis 100 die Menge ihrer Quadratzahlen ermitteln können. Jetzt fangen wir allmählich an, richtig funktional zu programmieren:
Wenn Sie bisher nur imperativ programmiert haben, werden Sie vermutlich nicht mit einer so einfachen Lösung gerechnet haben. Sie haben dabei zwei neue Elixir-Datentypen und ihre erste Funktion höherer Ordnung kennengelernt:
[1, 4, …, 10000]
ist eine Darstellung eines sehr grundlegenden FP-Datentyps: Listen (wie man mit Listen arbeitet, schauen wir uns weiter unten bei Rekursion an)1..100
stellt eine Range dar, die für Enumeration und Streaming (wir kommen in späteren Teilen der Serie zum Streaming; dann können wir die Quadratzahlen aller natürlichen Zahlen ermitteln) verwendet werden kann. Sie entspricht im Wesentlichen einer Liste[1,2,.. 99, 100]
map
ist eine von vielen Funktion aus dem ModulEnum
. Sie bildet eine aufzählbare Menge(daherEnum
für Enumeration) auf eine andere ab. Um das machen zu können, benötigt sie als 2. Parameter (der erste ist die Ausgangsmenge und kommt über die Pipe rein) die Abbildungsfunktion für die Elemente. Das macht sie zu einer Funktion höherer Ordnung
Das Besondere hier ist also der Parameter der map
-Funktion. Hier sollten Sie unsere selbst geschriebene Quadrat-Funktion sqr wiedererkennen, die für die eigentliche Transformation der Elemente sorgt. Die etwas komische Schreibweise &map/1
wird hier benötigt, um eine eindeutige Referenz auf die Funktion mit einem Parameter zu erzeugen. Wir werden gleich elegantere Notationsmethoden kennenlernen.
Was mache ich, wenn ich die Zahlen mit drei multiplizieren möchte (warum auch immer)? Am liebsten würde ich unsere Multiplikationsfunktion times/2
wiederverwenden. Aber die benötigt im Gegensatz zu sqr
zwei Parameter. Nun, ganz einfach, denn den zweiten Parameter kennen wir ja bereits: 3. So können wir ihn mitgeben:
Ist das cool, oder was? >&1
ist der Platzhalter des erhaltenen Parameters (map
stellt nur einen Parameter, das zu mappende Element, zur Verfügung, aber wir werden auch komplexere Funktionen höherer Ordnung kennenlernen). Wir hätten im oberen Beispiel statt &sqr/1
also auch &sqr(&1)
schreiben können. Verwenden Sie einfach die Schreibweise, die Ihnen und Ihren Kollegen lesbarer erscheint.
Spielen wir noch ein wenig mit der Pipe und Funktionen höherer Ordnung rum. Sagen wir, wir wollen nur die ungeraden Vielfachen von drei haben, müssen also irgendwie filtern. Können Sie raten, wie die Funktion heißt und wie wir sie einbauen? Richtig.
Es funktioniert. Übrigens hätte man in diesem Fall die filter
-Funktion auch vor die map
-Funktion stellen können, da nur die Multiplikation ungerader Zahlen selbst ungerade ist. Für die meisten Anwendungsfälle spielt die seqentielle Anordnung der Pipe natürlich schon eine Rolle, die sich aus den Anforderungen und dem sukzessiven Transformationsbedarf ergibt.
Sie haben dabei eine weitere Möglichkeit kennengelernt, nämlich eine anonyme Funktion inline zu definieren: fn x -> … end
. &rem/2
(für remainder) ist eine Kernel-Funktion zur Divisionsrest-Ermittlung (in anderen Sprachen auch als mod
bekannt).
Das ist doch alles recht einfach, vor allem im Vergleich zu imperativem Code. Aber abgesehen davon, dass wir die Pipe jetzt in der üblicheren und übersichtlicheren Schreibweise untereinander verwenden, drückt unser Code schon wieder etwas zu viel „Wie“ statt „Was“ aus und fängt syntaktisch an wie JavaScript auszusehen (noch nicht wirklich, aber wehret den Anfängen …).
Auf der Abstraktionsebene dieser Codestrecke möchte ich aber eigentlich nur deklarieren, dass ich ungerade Zahlen brauche, mich und den Codeleser nicht damit beschäftigen, wie ich feststelle, ob eine Zahl ungerade ist. Zumal eine solche Funktion womöglich anderweitig verwendbar ist. Bleiben Sie möglichst immer auf einem Abstraktionsniveau. Das können wir also mit mehr Wiederverwendungspotenzial und Klarheit schreiben. Unser erstes funktionales Refactoring:
Bitte sehen Sie für den Moment vom fachlichen Unsinn des Beispiels ab. Sie werden sich sicher bessere Anwendungsmöglichkeiten vorstellen können, sollten aber schon jetzt einen Eindruck davon erhalten haben, wie ausdrucksstark der Code sein könnte, den Sie bereits mit den bisher erlernten Sprachmitteln für Ihre Anwendung schreiben könnten.
Dabei haben wir eines der mächtigsten und unverzichtbaren Sprachmittel der funktionalen Programmierung noch gar nicht kennengelernt: Rekursion.
Play it again, Sam! Die Macht der Rekursion und grenzenloser Datentypen
Mancher von Ihnen hat sicher schon rekursive (d.h. sich selbst aufrufende) Funktionen entwickelt oder gesehen. Viele haben Angst vor Rekursion und/oder schlechte Erfahrungen damit gemacht: Stacküberläufe, Datentyp-Überläufe, Endlosschleifen sind typische Effekte bei Rekursion in imperativer Programmierung.
In FP ist Rekursion kein Schreckgespenst, sondern erleichtert das Leben erheblich. Zum einen lernt man bei FP schnell, wie ein Mathematiker zu denken, zum anderen sind FP-Sprachen auf rekursive Funktionen optimiert, weil unfassbar viele Probleme sehr elegant rekursiv lösbar sind. Das sehen wir jetzt.
Wie definiert ein Mathematiker Funktionen (und oft auch Mengen, z. B. die Peano-Zahlen)? Er denkt nicht in Schleifen, Summenvariablen, Ausstiegsbedingungen o.ä. wie ein imperativer Programmierer, sondern er unterscheidet Basisfälle und rekursive Definitionen. Überlegen Sie kurz, wie Sie bisher die Fakultäts-Funktion implementiert hätten (womöglich mit einer Variable, die in einer Schleife hochmultipliziert wird, also imperativ). Und jetzt schauen Sie, wie Sie das in Elixir machen können bzw. sollten:
Sie kodieren auf einfachste Weise, wie ein Mathematiker die Funktion definiert: x! = {1 für x = 0; x*(x-1)! für alle natürlichen Zahlen}
. Wie das Unterscheiden dieser beiden Fälle technisch funktioniert, schauen wir uns gegen Ende an. Stichworte sind Pattern Matching und Value Extraction. Im Moment würde uns das zu sehr aufhalten.
Dieser deklarative Programmierstil ist vielleicht ungewohnt, aber extrem einfach und sicher, sobald man sich einen entsprechenden Denkstil angeeignet hat. Wir werden später bei der Listenrekursion noch sehen, dass das nicht nur für offensichtlich rekursive mathematische Funktionen hilfreich ist.
Durch ihrer Optimierung auf Rekursion hat die FP außerdem einen weiteren unschlagbaren Vorteil gegenüber den üblichen Verdächtigen: Exaktere Datentypen. Mit „alle natürlichen Zahlen“ meint der Mathematiker nicht nur die, für die das Ergebnis zufällig in 64 Bit passt. Haben Sie schon mal in Java o.ä. Sprachen versucht, die Fakultät von 1000 auszurechnen? „Geht nicht!“ gilt nicht. Was ich schockierend finde: Die meisten Codebeispiele, die man zuhauf im Internet und in Lehrbüchern findet, verwenden ganz selbstverständlich den Datentyp long
und gehen oft noch nicht einmal auf die Rahmenbedingung ein, dass damit nur bis zu 20!
gerechnet werden kann – geschweige denn, wie man diese sprachentwurfs-bedingte Grenze überwinden könnte, denn es soll ja vorkommen, dass fachliche Anforderungen auch jenseits solcher willkürlicher Grenzen reichen. Leider wird auf diese Weise angehenden Programmierern das Fach vermittelt. Da wundert einen nichts mehr!
Schauen wir einfach, was Elixir von begrenzten Datentypen hält (ähnliches gilt für Haskell, Scala u.a. FP-Sprachen):
Sie können sich vorstellen, dass das ganz schön viele Ziffern sind. Um ein Haar hätte ich sie gezählt, da fiel mir ein, dass ein FP-Entwickler so etwas nicht nötig hat. Das hier sollten Sie mittlerweile problemlos verstehen:
Witzigerweise entspricht die Anzahl der Stellen der Telefonnummer meines Elternhauses (ich komme gebürtig aus einem kleinen Ort); vielleicht ist es ja meine persönliche 42 …
Hier haben Sie auch noch mal ein Best Practice: Statt die ggf. unerklärliche Pipe to_string |> String.length
direkt zu verwenden, weisen wir sie als adhoc-Funktion einem Alias zu, der ausdrückt, was sie macht: Sie ermittelt die Anzahl der Stellen einer Zahl. Die Regeln von „Clean Code“ lassen sich auch auf FP anwenden.
In Anlehnung an die an „Casablanca“ angelehnte Kapitelüberschrift sollten Sie jetzt allmählich denken „Ich glaube, das ist der Beginn einer wunderbaren Freundschaft“.
Elegant? Definitiv! Exakt? Verblüffend! Aber auch schnell?
Aber elegante, bei Entwicklern beliebte Programmiersprachen haben oft eine teure Kehrseite, man denke an Ruby. Falls Sie also jetzt im typischen „Java ist aber schneller und C noch mehr“-Reflex denken „Ja, aber die Performance …!“: Bei fac(1000) zuckt die REPL noch nicht mal, das Ergebnis (immerhin das Produkt von 1000 Multiplikationen mit einer Länge von 2568 Ziffern) steht da in Sekundenbruchteilen (und die längste Zeit benötigt vermutlich die Ausgabe der riesigen Zahl auf dem Display). Ähnlich ist es bei fac(10000) (35660 Stellen). Erst bei fac(30000)(121288 Stellen exakt berechnet!) fängt die Berechnung an, in den spürbaren Bereich mehrerer Sekunden zu gehen. Der Prozessor ist auch nur ein Mensch. Aber wir haben ja auch noch nicht über die Verteilung von Arbeitslast auf mehrere Prozessorkerne gesprochen. Das Thema Parallelisierung heben wir uns lieber für weniger triviale Probleme in folgenden Beiträgen auf (z. B. rekursive Ermittlung optimaler Züge bei Brettspielen, für deren kombinatorischen Explosion FP und Nebenläufigkeit a la Aktoren perfekte Lösungsmittel sind). Das wäre hier zwar auch schon möglich, aber bei einem so lächerlich kleinen Berechnungsproblem nun wirklich mit Kanonen auf Spatzen geschossen. Wann berechnet man schon mal solche Fakultäten in diesen Größenordnungen? Der Punkt ist: Man sollte es einfach können, wenn man es braucht.
Natürlich funktionieren auch die normalen Rechenfunktionen in diesen gigantischen Größenordnungen:
Zur Erinnerung: 77338
ist nicht das Ergebnis von 20000! – 10000!
, sondern die Anzahl der Stellen des im ersten Schritt der Pipe korrekt berechneten Ergebnisses!
Ich finde es ziemlich beeindruckend, so etwas im Sekundenbereich ausrechnen zu können, während die vermeintlich auf die Prozessorarchitekturen unserer PCs hochoptimierten Compiler vieler Programmiersprachen jenseits von 2^63 die Hufe hochreißen (hat übrigens gerade mal 19 Stellen!) – und wirklich schnell ist Rekursion in Java auch nicht, weswegen viele ja lieber auf Schleifen zurückgreifen.
Ich weiß nicht, wie die Compilerbauer von Elixir, Erlang, Haskell, Scala und Co. diese Grenzenlosigkeit mit dieser Performanz schaffen, aber das schöne ist ja gerade: Als FP-Programmierer sollte und muss ich mich nicht mit so mondänen Problemen wie Prozessor-Wortbreite oder Stacktiefe aufhalten lassen, sondern kann mich auf die zu lösenden Probleme konzentrieren. Selbst wenn nur dieser Vorteil für FP spräche, wäre er eigentlich schon zwingend.
Aber immer Kopieren statt Ändern ist doch langsam?
Viele argumentieren auch, dass es doch ein Performance-Fresser sein muss, dass in FP ständig neue Datenstrukturen entstehen, weil bestehende ja nicht geändert werden dürfen. Aber das stimmt nicht. Zum einen sollte sich jeder, der so kleinteilig optimieren will, die berühmten Worte von Donald Knuth in Erinnerung rufen: „Premature optimization is the root of all evil“. Das zeigt sich auch drastisch, wenn man bedenkt, das bei Multithreading durch die Synchronisierung gemeinsam benutzten Speichers ein Vielfaches der durch veränderliche Zustände vermeintlich gesparten Zeit vergeudet wird.
Außerdem sind viele Datenstrukturen bei FP überhaupt erst aufgrund der Nur-Lese-Eigenschaft optimierbar: Wenn einer LinkedList
ein neues Element vorangestellt wird, besteht die neue Liste nur aus diesem Element und dem Link auf die alte Liste (die sich ja nie ändern kann). Und schließlich kann FP fast ausschließlich mit dem (bekanntlich schnellen) Stack arbeiten und hat kaum vergleichsweise teure Heap-Aufrufe. Natürlich gibt es auch in FP die Möglichkeit, veränderliche Zustände festzuhalten. In Elixir geschieht das u.a. über Aktoren/Prozesse, die wiederum hochoptimiert bzgl. der Nutzung ihres absolut geschützten lokalen Speichers sind.
Rekursive Listenverarbeitung
Vielleicht wird mancher einwenden, dass die Welt der rein zahlenmengen-basierten Mathematik naturgemäß ein Heimspiel für funktionale Programmierung ist, allerdings für typische technische oder Business-Anwendungen nur eine relativ kleine Rolle spielt. D’accord!
Eine typischere Problematik der meisten Anwendungsdomänen ist das Verarbeiten von Datenmengen (Sortieren, Filtern, Summieren, Optimieren etc.). Natürlich geben die existierenden Funktionen höherer Ordung (z.B. des Enum-Moduls) dafür schon unglaublich viel her. Mittlerweile sollten Sie eine grobe Vorstellung davon haben, was diese anfangs schon gezeigte Codestrecke macht und wie Sie die aufgerufenen fachlichen Funktionen implementieren könnten:
Aber ich möchte Ihnen gegen Ende dieses langen Beitrags noch beibringen, wie Sie rekursiv mit Listen arbeiten und dabei selbst beliebige Funktionen höherer Ordnung schreiben können, die Sie dann für verschiedene fachliche Zwecke wiederverwenden können.
Stellen Sie sich vor, Sie sollen den höchsten Jahresumsatz einer gegebenen Menge von Kunden ermitteln und sehen noch weitere ähnliche Aufgaben auf sich zu kommen. Sie wollen daher von vornherein eine Funktion höherer Ordnung schreiben, die nicht auf Kunden beschränkt ist, sondern beliebige maximale Werte aus beliebigen Mengen mit beliebigen Attributen ermittelt. Überlegen Sie kurz, wie Sie das angehen würden (z. B. mit OOP-Mitteln, gerne aber auch mit den schon bekannten FP-Mitteln).
Ein Tip, den ich auch bei OOP schon immer gegeben habe und den man mit „Test First!“-Ansätzen wie TDD und BDD hervorragend umsetzen kann: Überlegen Sie immer, wie Sie selbst etwas gerne benutzen würden, wenn es das schon als API gäbe. Das führt Sie in der Regel zu gut verwendbaren Schnittstellen, in unserem Fall also Funktionen. Wie würden Sie das Problem des maximalen Jahresumsatzes also in idiosynkratischer Elixir-Syntax lösen wollen? Ich schlage folgendes vor:
Sprechender und dabei wiederverwendbarer kann Code kaum sein. Jeder, der das liest, wüsste sofort, dass hier der maximale Wert unter den Jahresumsätzen der Kunden gesucht wird (kunden steht hier stellvertretend für eine Menge von Kunden, die selbst schon als Funktionsparameter oder aus dem vorderen Teil der Pipe kommen könnte).
Man könnte sich auch schon etwas unter jahresumsatz vorstellen, wenn man an die map
-Beispiele denkt: Das ist nämlich eine Funktion, die den Jahresumsatz für jeweils einen Kunden ermittelt. Wie sie das macht, ist an dieser Stelle nicht wichtig. Denkbar wäre, dass sie einfach ein Attribut aus einer Datenstruktur zurückgibt, 12 Monatsumsätze aufaddiert, eine Datenbank-Abfrage macht etc. (Auch eine Datenbank ist in FP eine Sammlung von Funktionen. Denken Sie hier bitte nicht an die Optimierung von DB-Abfragen, es gibt gerade in deklarativer FP-Programmierung ausreichend Möglichkeiten, derlei Probleme zu lösen; das würde diesen Beitrag aber endgültig sprengen.)
Es spielt an dieser Stelle also schlichtweg keine Rolle, wie die Funktion das Problem löst, den Wert zu ermitteln, weil wir sie gerade nicht implementieren, sondern nur verwenden. Entscheidend ist nur Ihre Signatur, in diesem Fall (Kunde) -> Jahresumsatz
. Da wir max
als generische Funktion höherer Ordnung anlegen, können wir sogar verallgemeinern zu (Datenelement) -> VergleichbarerWert
. Hier kommt uns erneut entgegen, dass Elixir nicht getypt ist. Das bedeutet: Ohne große Regularien kann jeder eine beliebige Liste in unsere Funktion max
geben, solange er uns ebenfalls eine Funktion gibt, die für jedes Element dieser Liste vergleichbare Werte ermittelt. So wird in FP Wiederverwendung erzeugt. Vielleicht anfangs ungewohnt, aber man gewöhnt sich recht schnell daran. Man kann das zwar auch formaler machen (in Elixir über sogenannte Protokolle, vergleichbar den Interfaces in Java), aber für unseren und viele ähnliche Zwecke tut es das ungetypte Verfahren wunderbar.
Was statt wie ist also sehr hilfreich. Aber schauen wir uns jetzt aber an, wie wir diese Funktion max
implementieren müssten, wenn es sie noch nicht gäbe. Aber auch das machen wir wieder überwiegend deklarativ. Nochmal zur Erinnerung: Die Funktion erhält eine Menge uns unbekannter Datenelemente (ich sage bewusst nicht Objekte, weil wir in FP eher von Datenstrukturen reden, nicht von Klassen) und eine Funktion, die für jeweils eines dieser Elemente einen Wert ermitteln kann. Aufgabe der max
-Funktion ist es, den größten dieser Werte zu ermitteln (und zurückzugeben).
Könnten Sie es schon rekursiv lösen? Sie müssen wieder wie ein Mathematiker denken (teile und herrsche) und können drei Fälle definieren, deren letzter mit Listenrekursion arbeitet. Versuchen Sie doch mal die folgenden drei Funktionsdefinionen zu verstehen, bevor ich Ihnen die fehlenden Syntakexlemente erläutere:
Nach Jahren mühselig redundanter Programmierung mit Schleifen und temporären Variablen mag es Sie wundern bis frustrieren, aber das ist wirklich alles, was Sie definieren müssen. Und zwar ein für alle Mal. Sie können sich auf diese max
-Funktion verlassen (die Sie natürlich im echten Leben mit Unit-Tests gesichert haben) und sich ab jetzt auf fachliche Logiken und sinnvolle Implementierungen von valFn
beschränken.
Wie funktioniert max(elemente, wertfunktion)
, mathematisch gedacht? Im Prinzip wie die Fakultät weiter oben. Der maximale Wert zu einer Menge von Elementen ist definiert als:
- Undefiniert, wenn die Menge leer ist (wir hätten diese oberste Definition auch weglassen können, aber dann hätte es ggf. Laufzeit-Exceptions gegeben, jetzt muss sich der Aufrufer damit auseinandersetzen, dass es ggf. auch keinen Wert gibt, wenn es kein Element gibt, alternativ hätte man einen optionalen Parameter für den Defaultwert einführen können)
- Der Wert zum einzigen Element, wenn es nur ein Element gibt (Basisfall)
- Der größere Wert aus dem Wert des ersten Elements und dem maximalen Wert aller übrigen Elemente (Rekursionsfall)
Vergleichen Sie diese drei Fälle mit den drei implementierten Methoden. Sie sollten zumindest erahnen können, dass sie genau analog sind. Zum Verständnis des Codes fehlen Ihnen noch zwei zusammengehörige Konzepte, die ich Ihnen zum Abschluss dieses Beitrags noch antun muss.
Pattern Matching und Wertextraktion
Wenn Sie sich die drei Funktionsdefinitionen des letzten Beispiels ansehen, könnten Sie sich folgendes fragen: Wenn Elixir ungetypt ist und Funktionen somit nur nach Funktionsname und Anzahl der Parameter unterscheiden kann, wie kann es denn sein, dass es drei Funktionen namens max/2
gibt? Wie entscheidet das System, welche Methode die richtige ist?
Hier kommt ein gängiges Muster der FP zum Einsatz: Pattern Matching. Aus einer Reihe formal gleichwertiger Alternativen (max/2
, fakultaet/1
) wird die jeweils erste benutzt, deren Parametermuster den beim konkreten Aufruf übergebenen Parametern entspricht. Dieses Konzept ist unglaublich mächtig, da es weit über Typisierung und dem aus anderen Sprachen bekannten Overloading nach Typen hinausgeht. Man kann damit beliebig viele spezialisierte Funktionen implementieren, wo man anderswo große switch
-Blöcke im Funktionsbody bräuchte. Das zuvor erwähnte Phoenix macht sich das z. B. bei der Router-Implementierung zunutze.
In diesem Fall nutzen wir folgende Pattern, die für die Listenverarbeitung typisch sind:
[]
stellt eine leere Liste dar[head|tail]
stellt eine nicht leere Liste dar. Dabei isthead
das erste Element undtail
eine Liste der übrigen Elemente. Das ist ein sehr performantes Pattern für die rekursive Verarbeitung der in FP-Sprachen fast ausschließlich benutztenLinkedLists
[head|[]]
ist ein Beispiel für eine Kombination dieser beiden Pattern. Es besagt, dass es zwar ein Kopfelement gibt, aber dertail
eine leere Liste. Wir benutzen das, um unseren Basisfall einer einelementigen Liste zu definieren. Deshalb muss dieser auch vor dem Rekursionsfall stehen, weil dessen Muster auch auf die einelementige Liste passen würde. (Wir könnten auf diese Weise übrigens auch Patter für Listen mit z. B. drei Elementen und/oder bestimmten Werten der Elemente definieren, wenn das fachlich sinnvoll ist; hier brauchen wir es nicht)
Passend zu Pattern Matching gibt es Value Extraction. Wenn Sie sich die Funktionen 2 und 3 anschauen, werden Sie feststellen, dass im Funktionsrumpf der erste Parameter nicht als ganzes angesprochen wird (ja, er hat nicht mal einen Namen wie elements
, weil wir diesen nicht brauchen). Stattdessen wird mit den per Pattern zerschnittenen Teilwerten gearbeitet, die wir ja auch in unserer Definition brauchen. Dieses Feature von FP-Sprachen ist extrem hilfreich und vereinfacht das Denken in und das Umsetzen von sauberen mathematischen Definitionen – nicht nur, aber vor allem bei Rekursion.
Wenn Sie die Implementierung der dritten Methode, also des Rekursionsfalls genauer betrachten, werden sie die beiden Muster auch dort wiederfinden. Zunächst allerdings lernen Sie ein neues Sprachkonstrukt kennen, das Tupel ({wert1, … wertn}
). Diese Datenstruktur ist sehr hilfreich, wenn mehrere Werte gemeinsam transportiert werden müssen, z. B. als Rückgabewert einer Funktion. Hier machen wir es uns wie folgt zunutze: Wir müssen zwei Werte berechnen: Nämlich den Wert des head
-Elements (hier first
genannt) und den maximalen aller Werte aus dem tail
(hier remaining
genannt).
Da vor allem das letztere eine ggf. teure Funktion ist, wir die Ergebnisse für das folgende Pattern-Matching aber zweimal brauchen, berechnen wir sie am Anfang, verpacken sie in ein Tupel und matchen dieses dann gegen die zwei definierten Fälle. Dieses Matching machen wir wiederum mit case … do … end
, Elixirs Variante zu Scalas match
-Funktion. Eine Art switch
, aber viel mächtiger. Im ersten Fall können Sie auch sehen, dass man die Matches mit sogenannten Guards verfeinern kann. Die Match-Klausel würde nämlich für beide Fälle passen. Den ersten wollen wir aber nur anwenden, wenn das erste Element einen größeren Wert hat als der Rest.
Nur noch eine Erläuterung, da sie sich vielleicht über die Underscores (_) wundern. Mit diesen kann man ausdrücken, dass das Element an dieser Stelle für das Muster keine Rolle spielt und/oder man es im Code nicht verwenden möchte. Da der Wert des ersten Elements die erste Bedingung nicht erfüllt hat, brauchen wir ihn im zweiten Fall nicht und können ungeprüft den anderen Wert zurückgeben.
So einfach wie möglich, aber nicht einfacher!
Viel komplizierter als die oben gezeigte Implementierung von max
sollten saubere FP-Funktionen typischerweise nicht werden – im Gegenteil: Man sollte sich angewöhnen, bei zu langen Methoden zu fragen, ob sie nicht mehrere Dinge vermischen und man den Code noch einfacher machen kann.
Und das machen wir auch hier: Im letzten Beispiel könnte man argumentieren, dass das Vermischen der Wertermittlung und der Ermittlung des Maximalwerts in einer Funktion max/2
eigentlich ein wenig redundant ist, weil es ja schon eine Funktion Enum.map/2
zum Umwandeln von Wertemengen gibt. In der Tat hätte man auch schreiben können:
Sie können das inzwischen lesen: Zunächst werden die Kunden in ihre Jahresumsätze transformiert (ge„map
“t). Danach reicht eine Funktion max/1
, die den größten Wert aus einer Liste von Werten bestimmt. Natürlich wäre max/1
etwas einfacher:
Beides sind valide Alternativen. Am Ende zählt, welcher Code lesbarer ist. Und ich persönlich finde, dass max(jahresumsatz) an der Aufrufstelle eigentlich klarer den Sinn (das „Was“) ausdrückt: Wir wollen den größten Jahresumsatz haben. Die max/2‑Variante kann für den Aufrufer also lesbarer sein. Trotzdem müssen Sie die max/2‑Variante nicht so kompliziert implementieren wie oben. Verlagern Sie einfach das „Wie“ mit map in eine zweite Funktion und schreiben Sie unter Verwendung der ohnehin sinnvollen max/1‑Funktion einfach noch das hier:
So haben Sie den Vorteil beider Ansätze. Am Ende geht es um den Code mit der besten Les-/Wartbarkeit. Sie sollten jetzt auch erkennen, warum ich anfangs die steile These aufgestellt habe, dass FP viel besser für Wiederverwendung geeignet ist als OOP.
Grundsätzlich gilt: In FP gibt es viele Wege zum Ziel. Und das Ziel ist Code der bestmöglichen Qualität. Dabei ist die einfachste und eleganteste (d.h. mathematisch sauberste) Implementierung meistens die beste.
Einfach, sicher, produktiv: Was will man mehr?
Wir sind am Ende unserer Einführung angelangt. Ich hoffe, Sie können nachvollziehen, warum ich FP im Titel mit diesen Attributen versehen habe. Eine gute FP-Funktion ist
- einfach, indem ihre Arbeitsweise auf den ersten Blick nachvollziehbar ist
- sicher, indem sie nichts anderes als eine definierte Transformation macht (dadurch übrigens auch hervorragend testbar)
- gut (wieder)verwendbar und einfach mit anderen Funktionen komponierbar, z. B. in Pipes
FP erhöht die Qualität und Produktivität nachhaltig, indem Code entsteht, der einheitliche Abstraktionen besitzt und auf jeder Abstraktionsebene gut nachvollziehbar ist, ohne den Ersteller oder den Wartungsentwickler mental zu überfordern.
Ich hoffe, die Lektüre hat sich für Sie gelohnt. Vielleicht konnte ich Sie etwas für FP und seine Vorteile interessieren. Auf jeden Fall sollten Sie jetzt etwas konkretere Vorstellungen von diesem geheimnisumwitterten Paradigma haben. Vielleicht mögen Sie selbst etwas rumexperimentieren. Natürlich ist das noch nicht alles, aber das wesentliche Rüstzeug haben Sie jetzt. Und Elixir ist nicht nur die perfekte Sprache zum Einstieg, sondern auch die richtige Wahl in Hinblick auf Betriebsqualität.
Sie haben schon jetzt einen guten Eindruck bekommen, wie einfach man sprechenden Code wie den eingangs erwähnten entwickeln kann. In den nächsten Beiträgen dieser Serie werde ich Ihnen zeigen, wie man Funktionen als Grundbausteine verblüffend einfach zu immer größerer Semantik komponieren kann. Dabei lassen sich auf einmal im Handumdrehen Probleme lösen, deren Komplexität einem in imperativer Programmierung erhebliches Kopfzerbrechen gemacht hätte, wenn man sich überhaupt herangetraut hätte.
Gerne weise ich auch schon daraufhin, dass wir demnächst ein hands-on-Training für FP mit Elixir anbieten werden. Wir bieten das auch gerne als Inhouse-Training an oder begleiten Ihre Teams mit Coaching auf dem Weg in die einfache, sichere und produktive Welt der funktionalen Programmierung.