Reduzieren reduziert Aufwände

Wer gerne kocht, weiß: Das Geheimnis einer guten Soße liegt im Reduzieren, durch das die geschmackliche Essenz stärker zu Tage tritt. Eine ähnliche Technik gibt es bei der Funktionalen Programmierung: Reducer oder Reduce-Funktionen reduzieren Datenmengen auf einzelne Werte (z.B. Summen, Durchschnitte, Statistik). Für viele FP-Anfänger sind Reducer ein Buch mit sieben Siegeln. In diesem Beitrag möchte ich zeigen, dass sie eigentlich ein sehr einfaches und naheliegendes Konzept sind.
Wie schon bei der Einführung in FP verwende ich für die Codebeispiele Elixir, weil ich diese Sprache für elegant und selbsterklärend halte.
Wie summiert man ohne Schleifen und Variablen?
Denken Sie an eine ziemlich einfache Aufgabe: Um eine Anzahl bestellter Artikel in einem Auftrag zu ermitteln, müssen Sie die bestellte Anzahl aller Positionen aufaddieren. Schauen wir uns an, wie man ein solches Problem imperativ gelöst hat, z. B. in Java:
Eine Menge Code! Bevor es generische Klassen und Iteratoren gab, wäre das noch mehr gewesen. Und noch dazu unsicher durch die Verwendung veränderbarer Zustände, schlecht testbar usw. Dennoch ist zu befürchten, dass die Mehrzahl der Software-Entwickler das immer noch für den richtigen Weg zur Lösung hält.
Eigentlich möchte man das Konzept „summiere die bestellte Anzahl aller Positionen“ doch auch einfacher im Code ausdrücken können. Schauen wir, wie man das in FP schreiben könnte:
Das ist noch nicht der „richtige“ bzw. eleganteste Weg (der kommt weiter unten). Ich wollte sie zunächst an reduce heranführen.
Zunächst fällt die völlig andere Codestruktur auf: FP arbeitet statt mit Klassen mit Datenstrukturen und Funktionen, die sie umwandeln. auftrag
ist eine Datenstruktur, die den Auftrag enthält. Im nächsten Schritt wird diese durch postionen
in eine Menge Positionen transformiert (wie, spielt hier keine Rolle, im einfachsten Fall sind die Positionen wie bei der OOP-Klasse Bestandteil der Stuktur von auftrag
). Anschließend wird für diese Menge mit einer entsprechenden Funktion die Summe der bestellten Artikel berechnet. (Die Zuweisung zum Alias anzahl
ist hier nur erfolgt, um es analog zum Java-Beispiel zu halten, wäre in FP aber eigentlich unüblich.)
Entscheidend ist aber die Implementierung von summeBestellteAnzahl
: Aus den sechs Zeilen des Bodys der Java-Funktion ist eine geworden: eine reduce-Funktion.
Reduce macht aus vielen Werten einen
Nur etwas Theorie. Schauen wir uns die Funktionssignatur von reduce
an:
reduce
erhält eine Menge des Datentyps a
(unsere Positionen) und transformiert sie in einen Wert des Datentyps b
(hier Int
). Sie macht das durch Akkumulation („An-/Aufhäufung“). Dafür braucht sie zwei weitere Parameter: Den Initialwert des Akkumulators vom Typ b
(bei Summen typischerweise 0) und eine Akkumulatorfunktion. Diese bekommt wiederum zwei Parameter: ein Element der Menge vom Typ a
, also hier jeweils eine Position, und den Wert des Akkumulators. Ihre Aufgabe ist es, den Wert des Akkumulators durch Akkumulieren des Elements zu ermitteln (hier, indem sie die bestellte Anzahl der jeweiligen Position zur bisherigen Summe dazu addiert). Das wird vielleicht deutlicher, wenn wir die Akkumulatorfunktion aus dem obigen Beispiel etwas klarer hinschreiben:
Die reduce
-Funktion macht also (natürlich) inhaltlich das gleiche wie die Schleifenimplementierung in Java: Sie ruft rekursiv die Akkumulatorfunktion mit den Elementen auf, bis diese aufgebraucht ist, und antwortet dann den akkumulierten Wert. Und sie macht das rein funktional, ohne (unsichere) Variablen und ohne überflüssigen Schreibaufwand – und wiederverwendbar.
Das ist noch nicht funktional genug
Wenn man schon etwas mehr FP-Erfahrung hat, stört einen womöglich, dass sowohl der Aufruf als auch die Funktion summeBestellteAnzahl
nicht wirklich eleganter FP-Code sind. Man sollte immer versuchen, atomare Funktionen zu schreiben und wiederzuverwenden. Was wir eigentlich schreiben wollen, ist:
Hier sehen wir wieder den Vorteil von FP-Code: Ausdrucksstärke. Die Pipe oben beschreibt nämlich genau, wie laut Spezifikation die Gesamtanzahl definiert ist: Nimm vom Auftrag die Positionen, von diesen die bestellte Anzahl und summiere das auf. Voila! Einfacher kann Code nicht sein. Schwieriger sollte er nicht sein.
reduce
ist so mächtig wie map
Dem einen oder anderen imperativen Programmierer mag das alles als zu umständlich erscheinen, um bloß eine Summe zu berechnen. Natürlich wäre der Lernaufwand unverhältnismäßig, nur um ein paar Zeilen Code zu sparen. Aber gehen Sie mal vor ihrem geistigen Auge typische Java- oder C++-Programme durch: 60–80% des Codes bestehen typischerweise aus solchem imperativen Boilerplate-Code wie Schleifen, Zählervariablen etc.
Der eigentliche Witz bei FP-Funktionen ist ja ihre vielgestaltige Einsetzbarkeit, weil sie Funktionen als Parameter erhalten können. (Sie erinnern sich: Man spricht von „Funktionen höherer Ordnung“) Und das macht sie universell wiederverwendbar in einem Maße, wie es OOP nie erreicht hat (weil jeder Wiederverwendungsersparnis immer auch Mehraufwand wie abstrakte Klassen, Interfaces u.ä. erforderte, was den Code oft zusätzlich unlesbarer machte). Hier als Beispiel drei sehr häufig verwendete Funktionen zum Transformieren von Mengen:
map
kann eine Menge von Typa
in eine gleichmächtige Menge von Typb
umwandeln und beötigt dazu eine Funktiona -> b
(a
undb
können gleich sein)filter
kann von einer Menge eines Typs eine Untermenge des gleichen Typs bilden und benötigt dazu ein sogenanntes Prädikat, eine Funktiona -> Boolean
reduce
transformiert eine Menge vona
auf einen Wert vonb
und benötig dazu zwei Parameter, den Initalwert vonb
und die Akkumulatorfunktion(a, b) -> b
Wenn Sie dieses Grundprinzip verinnerlicht haben (und das geht ziemlich schnell, wenn man nicht an Schleifen, Variablen und Boilerplate hängt), werden Sie verblüfft sein, wie häufig sich diese funktionalen Lego-Bausteine in beliebigen Kombinationen einsetzen lassen (die o.g. Funktion sum: Enum Int -> Int
lässt sich zum Summieren jeder Menge von Zahlen verwenden) und wie einfach man selber welche daraus bauen kann. Das ist die Magie der funktionalen Programmierung!
Wie leicht man eine Funktion wie reduce
auch selbst implementieren kann, haben dieses Muster im ersten Beitrag zur FP bereits als Listenrekursion kennengelernt:
reduce
für eine Liste, einen Akkumulator und eine Akkumulationsfunktion ist
- der Akkumulator, wenn die Liste leer ist (Basisfall)
reduce
für den Rest der Liste mit dem Wert der Akkumulationsfunktion für das erste Element und den bisherigen Akkumulator (gibt einen neu akkumulierten Wert), wenn die Liste mindestens ein Element (head
) und restliche (tail
) enthält
Hieraus wird deutlich, dass sich Enum.reduce
weder für den Typ a
noch b
interessiert, sondern es komplett fnAcc
überlässt, die Elemente in acc
zu aggregrieren. Das heißt: Natürlich kann Typ b
bei Enum.reduce
eine beliebig komplexe Datenstruktur sein, z. B. eine Statistik oder auch eine andere Menge. Konkrete Beispiele dafür werde ich mir für ein anderes Mal aufheben.
Zum Abschluss daher hier nur ein weiteres einfaches Beispiel. Was es macht, sollte selbst erklärend sein:
Hinweis: Normalerweise hätte man dieses max
auch als Listenrekursion implementieren können. Ich wollte Ihnen hier nur zeigen, wie man reduce
problemlos für andere Dinge als Summen verwenden kann.
Schreiben Sie Code, den Sie auch nächstes Jahr noch verstehen
Wie ich schon mehrfach betonte, ist FP kein Selbstzweck, sondern führt zu einfachem, sicheren und ausdrucksstarken Code, der massiv die Produktivität der Software-Entwicklung erhöhen kann, denn: Boilerplate-Code wie der eingangs gezeigte muss nicht nur geschrieben, sondern auch getestet und gelesen werden&bsp;– und erfordert häufig genug Debugging, weil mehr Code mehr Fehlermöglichkeiten bedeutet; welcher Programmierer hat z. B. noch nie versehentlich die Summenvariable in der Schleife neu definiert und sich dann gewundert, dass die äußere nicht aufaddiert wird?
Ich kann Sie nur ermutigen, sich allmählich an dieses Paradigma der Zukunft anzunähern. In den nächsten Jahren wird FP die imperative Programmierung (inkl. OOP) verdrängt haben (von der Java-Legacy abgesehen, die uns natürlich als Ballast erhalten bleiben wird wie die COBOL-Systeme, die in den 60er bis 80er Jahren eingeführt wurden). Das ist nicht nur wegen der Produktivitätsvorteile und des Fachkräftemangels zwangsläufig, sondern auch eine technische Notwendigkeit, weil imperative Programme einfach für die Herausforderungen massiv verteilter und nebenläufiger Systeme ungeeignet sind, wie Digitalisierung und IoT sie erfordern.
Wenn Sie Hilfe brauchen oder sich einfach nur austauschen wollen, sprechen Sie mich bitte jederzeit an oder benutzen Sie die Kommentarfunktion. Ich freue mich auf das Gespräch mit Ihnen.