composeKomplexität zerlegen
von Christian HeynEin wichtiges Konzept der funktionalen Programmierung ist die Komposition. Also das “Hintereinanderschalten” von kleinen, spezialisierten Funktionen, die zusammen eine aufwendige Aufgabe bewältigen.
Eine komplexe Aufgabe in weniger komplexe Einzelteile zu zerlegen ist Übungssache. Zu Beginn bekommt man Anforderungen wie “Eine Funktion, die für einen Warenkorb Artikelpreise von Netto zu Brutto umrechnet und eine Gesamtsumme liefert”. Easy, nicht wahr? Doch sind die To-dos meist vielfältiger:
- Mehrwertsteuer anwenden
- Rabatt (in Euro) anwenden
- Durch den Rabatt keine negativen Preise
- Rundung auf 2 Dezimalstellen
- Berechneter Bruttopreis mal Anzahl des Artikels
Also haben wir doch Einiges zu tun. Eine Funktion, die all diese Dinge miteinander vereint, wird auch zunehmend aufwendiger zu testen.
Genau dieser Herausforderung werden wir uns stellen indem wir die Komplexität in kleine Teile zerlegen.
Stück für Stück
Da die geforderten Berechnungen auf Preise bezogen sind und nicht auf den Artikel selbst, schreibe ich im ersten Schritt nur Funktionen, die Zahlen oder Arrays von Zahlen verarbeiten. Los gehts:
(1) Mehrwertsteuer anwenden:
const mwst = n => (n * 1.19);
(2) Rabatt (in Euro) anwenden:
const rabattInEuro = r => n => (n - r);
(3) Durch den Rabatt dürfen keine negativen Preise entstehen:
const mindestens0 = n => Math.max(0, n);
(4) Rundung auf 2 Dezimalstellen:
const runden = n => (Math.round(n * 100) / 100);
(5) Berechneter Bruttopreis mal Anzahl des Artikels:
const multipliziere = n => m => (n * m);
Fünf wunderbare Einzeiler. Zugegeben, in echten Projekten schreibt man nicht nur Einzeiler. Doch ist unser Beispiel nicht weit entfernt von einem echten Anwendungsfall im Bereich E-Commerce.
Somit haben wir unsere Funktionalität (unsere “Business-Logik”) grob abgebildet. Diese Einzeiler sind so überschaubar, dass es ein Leichtes wäre sie von vornherein testgetrieben zu erarbeiten.
Die Komposition
Ich benutze gern ramda, ein npm Package mit vielen nützlichen, funktionalen Helfern. Doch für dieses Projekt steht die Funktion compose im Vordergrund.
import { compose } from 'ramda';
(Es gibt viele andere npm Packages, welche eine Funktion wie compose bereitstellen)
Die Funktion compose erwartet Funktionen als Parameter – sinnvollerweise mindestens zwei. Doch anstatt die Funktionen sofort auszuführen erhält man als Rückgabewert eine neue Funktion – also die Komposition aus allen übergebenen Funktionen. Schematisch dargestellt sind also diese beiden Zeilen äquivalent:
const f = compose(f3, f2, f1)
const f = x => f3(f2(f1(x)))
Wie du vielleicht erkennst, liegt der Vorteil von compose darin, dass man ohne diesen Klammer-Wahnsinn beliebig viele Funktionen hinzufügen kann. Zu beachten ist die Reihenfolge der übergebenen Parameter. Die Funktionen werden von rechts nach links abgearbeitet (sowie das Beispiel ohne compose zeigt).
Sollte dir diese Reihenfolge unnatürlich erscheinen, bietet ramda Abhilfe. Die Funktion pipe fügt wie compose alle Funktionen zusammen, jedoch in umgekehrter Reihenfolge.
compose(f3, f2, f1) === pipe(f1, f2, f3)
(Im Folgenden weiter mit compose)
Zu beachten ist weiterhin, dass compose immer eine Funktion zurückgibt. Möchte man aber diese Funktion direkt ausführen, kann man dies folgendermaßen umsetzen:
const resultat = compose(f3, f2, f1)(daten);
Oder nochmal einmal aufgeschlüsselt:
const resultat = compose( /* Parameter für compose */ )( /* Parameter für die Funktion, die compose gerade erzeugt hat */ );
Das Ganze zusammensetzen
Gehen wir nun davon aus, dass wir unsere Daten in folgender Form erhalten:
interface Artikel { preis: number, anzahl: number, rabatt?: number }
Mit dieser Struktur können wir nun eine Funktion erarbeiten, die wir artikelBrutto nennen und die unsere Einzeiler vereint:
const artikelBrutto = (artikel) => compose(
runden,
mindestens0,
multipliziere(artikel.anzahl),
mwst,
mindestens0,
rabattInEuro(artikel.rabatt),
)(artikel.preis)
Von unten nach oben gelesen ist diese Funktion selbsterklärend. Auf Grundlage des Artikelpreises wird der angegebene Rabatt angewendet, rabattInEuro. Durch den Rabatt darf kein negativer Wert entstehen, mindestens0. Mehrwertsteuer wird angewendet, mwst. Das Ganze wird mit der Anzahl des Artikels im Warenkorb multipliziert, multipliziere. Vorsichtshalber sollte noch einmal auf negative Werte geprüft werden (falls ein Spaßvogel -3 Bücher bestellen möchte), mindestens0. Und zum Schluss wird der Wert noch gerundet, runden.
Lass uns das Erarbeitete nun ausprobieren:
const resultat = artikelBrutto({ rabatt: 7, anzahl: 3, preis: 31.95, }) // resultat 89.07
Eine Zwischenbilanz
Unser gesamter Code sieht bis jetzt so aus:
import { compose } from 'ramda';
const mwst = n => (n + (n * 0.19));
const rabattInEuro = r => n => (n - r);
const mindestens0 = n => Math.max(0, n);
const runden = n => (Math.round(n * 100) / 100);
const multipliziere = n => m => (n * m);
const artikelBrutto = (artikel) => compose(
runden,
mindestens0,
multipliziere(artikel.anzahl),
mwst,
mindestens0,
rabattInEuro(artikel.rabatt),
)(artikel.preis)
Wenn du mich fragst, ist das eine überschaubare Menge an Codezeilen. Ein Vorteil solcher kleinteiligen Funktionen ist u.a. die Wiederverwendbarkeit. Ich konnte mindestens0 zweimal nutzen. Kein neuer Code musste geschrieben werden. Es musste lediglich in der Komposition etwas verwendet werden, was schon existierte.
Es gibt nur Funktionen und deren Parameter
Was weiterhin auffällt ist, dass es keine Variablen mit Zwischenwerten gibt. Jede Verwendung von const ist ausschließlich für Funktionen. Es gibt nur Funktionen und deren Parameter.
Das nächste Feature
Wie verhält es sich mit der Skalierbarkeit, wenn ich alle meine Anforderungen mit solch einer Komposition und kleinen Funktionen abbilde? Schauen wir einfach mal, wie wir da ein neues Feature einbauen können.
Neben Rabatten gibt es auch oft Aktionen in Online-Shops, wie zum Beispiel “Kauf mindestens drei, bekomme eins davon gratis!”.
const aktionEinsGratis = (anzahl) => (
(anzahl >= 3) ? (anzahl - 1) : anzahl
);
Oder “Summer-Sale, 30% auf alles! (Außer 1Euro Artikel)”:
const summerSale = (einzelPreis) => (preis) => (
(einzelPreis > 1) ? (preis * 0.7) : preis
)
Das alles in unsere bestehende Komposition eingebaut:
const artikelBrutto = (artikel) => compose(
runden,
mindestens0,
summerSale(artikel.preis),
multipliziere(aktionEinsGratis(artikel.anzahl)),
mwst,
mindestens0,
rabattInEuro(artikel.rabatt),
)(artikel.preis)
Ein weiteres To-do ist natürlich, dass wir eine Funktion bereitstellen, die einen ganzen Warenkorbwert anhand von Artikeln berechnet – anders gesagt also ein Array von Artikeldaten entgegen nimmt. Hierzu empfehle ich den zusätzlichen Import der Funktionen map und reduce aus ramda. Diese Helfer sind die verbesserten Versionen von Array.map und Array.reduce, denn ihnen muss man nicht alle Parameter sofort übergeben (kann man aber).
import { compose, map, reduce } from 'ramda';
/* Unser Code von oben */
const summiere = reduce((acc, x) => acc + x, 0);
const warenkorbWert = compose(
runden,
summiere,
map(artikelBrutto),
);
Herauszufinden was map und reduce genau tun, überlasse ich dir, lieber Leser.
Unser gesamter Code im Überblick:
import { compose, map, reduce } from 'ramda';
const mwst = n => (n + (n * 0.19));
const rabattInEuro = r => n => (n - r);
const mindestens0 = n => Math.max(0, n);
const runden = n => (Math.round(n * 100) / 100);
const multipliziere = n => m => (n * m);
const aktionEinsGratis = (anzahl) => (
(anzahl >= 3) ? (anzahl - 1) : anzahl
);
const summerSale = (einzelPreis) => (preis) => (
(einzelPreis > 1) ? (preis * 0.7) : preis
)
const summiere = reduce((acc, x) => acc + x, 0);
const artikelBrutto = (artikel) => compose(
runden,
mindestens0,
summerSale(artikel.preis),
multipliziere(aktionEinsGratis(artikel.anzahl)),
mwst,
mindestens0,
rabattInEuro(artikel.rabatt),
)(artikel.preis)
const warenkorbWert = compose(
runden,
summiere,
map(artikelBrutto),
);
Wow, haben wir viel geschafft! Die Anforderungen sind abgebildet. Wenn du, werter Leser, noch nicht genug hast, dann implementiere doch noch das Feature: “12 Euro pauschale Versandkosten. Wenn der Warenkorbwert 100 Euro überschreitet, dann versandkostenfrei”.
Ein Wort zum Debugging
Wie schon erwähnt gibt es keine Variablen mit Zwischenwerten. Wie also kann ich jetzt etwas näher untersuchen? Wie soll ich beispielsweise einen Wert in der Konsole ausgeben?
Denk in Funktionen! Auch die Ausgabe eines Wertes ist eine Funktion, die wir in unsere Komposition einbauen können.
const log = (zwischenwert) => {
console.log(zwischenwert);
return zwischenwert;
}
Diese Funktion bekommt Daten, gibt diese in der Konsole aus, verändert nichts und gibt die selben Daten wieder zurück. Zu nutzen wie folgt:
/* ... */
const artikelBrutto = (artikel) => compose(
runden,
mindestens0,
summerSale(artikel.preis),
log,
multipliziere(aktionEinsGratis(artikel.anzahl)),
mwst,
mindestens0,
log,
rabattInEuro(artikel.rabatt),
)(artikel.preis)
/* ... */
Nun werden die Werte an den gewünschten Stellen ausgegeben. Vergiss nur nicht log wieder zu entfernen, wenn du fertig bist. Mein Rat an dieser Stelle ist jedoch: Erarbeite alle Funktionen von vornherein testgetrieben. Debugging von Produktiv-Code ist hart. In den Tests hingegen kannst du die seltsamsten Edge-Cases einmalig abbilden und für immer fixieren. Wenn du den Bug in einem Test nachstellen konntest, wird es nicht lange dauern bis du ihn behoben hast. Jedes console.log welches am Ende gelöscht wird, verschleiert deinen Kollegen die Arbeit und die Information rund um den Fehler. Sie sehen zwar einen Bugfix im Code, können aber im schlimmsten Fall kein Grund daraus erkennen.
Abschließende Worte
Ein- und Dreizeiler. Das sind die Bausteine, die unsere Aufgabe gelöst haben. Es geht hier nicht darum, wenig Code zu produzieren. Es geht vielmehr darum, soviel Code wie möglich testbar zu erarbeiten und, wenn möglich, mehrfach zu verwenden. Die Funktion compose zu nutzen mag anfangs ungewöhnlich aussehen. Doch wenn im gesamten Projekt diese Konvention durchgezogen wird, ist es dieses „Schema F“, mit dem alle komplexen Aufgaben bewältigt werden können.
Und meiner Erfahrung nach ist es manchmal bei großen Projekten überraschend, wenn man keine neuen Funktionen mehr schreiben muss, sondern das Bestehende neu zusammensetzt.
Wenn du vor einer komplexen Aufgabe stehst, hilft es (wie oben) die einzelnen Teilaufgaben aufzulisten.
Du hast Fragen, Anmerkungen oder einen Fehler entdeckt?
Schreib mir gern eine E-Mail an christian-dev@mailbox.org
oder nutze direkt das folgende Formular.
Dein Feedback wird für niemanden ersichtlich sein. Deine Nachricht wird nicht auf dieser Website veröffentlicht oder weiter gereicht ohne deine schriftliche Einwilligung.
Dir gefällt der Artikel "compose - Komplexität zerlegen"? Ich teile meine Expertise gern mit dir und deinem ganzen Team. Neben individuellen Workshops biete ich auch den Workshop "Produkt-Entwicklung mit React" an. Wenn das interessant für dich klingt dann lass uns gern persönlich sprechen.