ComposeKomplexität zerlegen
Von Christian Wünsche | Lesedauer ~ 5 MinutenEin 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 “Erarbeite eine Funktion, welche für einen Warenkorb Artikelpreise von Netto zu Brutto umrechnet und eine Gesamtsumme liefert!”
- 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 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.
Dies bildet unsere Funktionalität (unsere “Business-Logik”) grob ab. Diese Funktionen sind so überschaubar, dass es ein Leichtes gewesen wäre sie von vornherein testgetrieben zu erarbeiten.
Die Komposition
Ich benutze gern ramda, ein npm Package mit vielen nützlichen, funktionalen Helfern. Für dieses Projekt steht die Funktion
compose
im Vordergrund.import { compose } from 'ramda';
(Es gibt viele andere npm Packages, welche eine Funktion wie
bereitstellen)compose
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 man 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 erreichen:
const resultat = compose(f3, f2, f1)(daten);
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:
type Artikel = { preis: number, anzahl: number, rabatt: number | undefined }
Mit dieser Struktur können wir nun eine Funktion erarbeiten, die wir
artikelBrutto
nennen und welche die Einzeiler von oben vereint:1function artikelBrutto(artikel) {
2 const composition = compose(
3 runden,
4 mindestens0,
5 multipliziere(artikel.anzahl),
6 mwst,
7 mindestens0,
8 rabattInEuro(artikel.rabatt),
9 );
10
11 return composition(artikel.preis);
12}
Dieselbe Ausführung können wir noch kürzer schreiben:
1const artikelBrutto = (artikel) => compose(
2 runden,
3 mindestens0,
4 multipliziere(artikel.anzahl),
5 mwst,
6 mindestens0,
7 rabattInEuro(artikel.rabatt),
8)(artikel.preis);
Von unten nach oben gelesen (weil wir
compose
nutzen und nicht pipe
)
ist diese Funktion selbsterklärend. Auf Grundlage des Artikelpreises
wird der angegebene Rabatt angewendet, (Zeile 7) rabattInEuro
.
Durch den Rabatt darf kein negativer Wert entstehen, (Zeile 6) mindestens0
.
Mehrwertsteuer wird angewendet, (Zeile 5) mwst
.
Das Ganze wird mit der Anzahl des Artikels im Warenkorb multipliziert, (Zeile 4) multipliziere
.
Vorsichtshalber sollte noch einmal auf negative Werte geprüft werden
(falls ein Bot -3 Artikel bestellen möchte), (Zeile 3) mindestens0
.
Und zum Schluss wird der Wert noch gerundet, (Zeile 2) runden
.Führen wir
artikelBrutto
nun einmal aus:const resultat = artikelBrutto({ rabatt: 7, anzahl: 3, preis: 31.95, }) // resultat 89.07
Eine Zwischenbilanz
Das gesamte Projekt sieht im Moment so aus:
1import { compose } from 'ramda';
2
3const mwst = n => (n + (n * 0.19));
4const rabattInEuro = r => n => (n - r);
5const mindestens0 = n => Math.max(0, n);
6const runden = n => (Math.round(n * 100) / 100);
7const multipliziere = n => m => (n * m);
8
9const artikelBrutto = (artikel) => compose(
10 runden,
11 mindestens0,
12 multipliziere(artikel.anzahl),
13 mwst,
14 mindestens0,
15 rabattInEuro(artikel.rabatt),
16)(artikel.preis)
Eine überschaubare Anzahl von Codezeilen. Der Vorteil solcher kleinteiligen Funktionen ist u.a. die Wiederverwendbarkeit. Die Funktion
mindestens0
konnte zweilmal genutzt werden.
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 alle Anforderungen mit kleinen Funktionen abgebildet und mit compose zusammengesetzt werden? Wie mann man ein neues Feature einbauen?
Neben Rabatten gibt es oft Aktionen in Online-Shops, wie beispielsweise “Kaufe mindestens drei, bekomme eins 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 die 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 ausgedrückt 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),
);
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),
);
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.Ein- und Dreizeiler. Das sind die Bausteine, welche die Anforderungen abgedeckt 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.Wenn du vor einer komplexen Aufgabe stehst, hilft es (wie oben) die einzelnen Teilaufgaben aufzulisten. Und zu Überlegen ob diese Teilaufgaben schon angemessene Funktionen definieren.