ComposeKomplexität zerlegen

Von Christian Wünsche | Lesedauer ~ 5 Minuten

Ein 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

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 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.

FPJavaScript