Curry Pattern in JSSpezialisierte Funktionen erstellen
Von Christian Wünsche | Lesedauer ~ 5 MinutenCurry ist nicht nur ein vorzügliches Gericht. Es ist zudem ein Muster welches nach einem amerikanischen Mathematiker benannt ist. (Mathematik ist für die folgenden Zeilen jedoch nicht von Nöten.) Currying hilft uns Funktionen wiederzuverwenden und unseren Code ein wenig aussagekräftiger zu machen.
Haskell Brooks Curry griff die mathematische Arbeit eines gewissen Moses Schönfinkels auf und konkretisierte das Verfahren im Jahre 1958 umfangreich. Auch die Programmiersprache Haskell ist nach Haskell Brooks Curry benannt. In Haskell ist das Currying ein nativer Bestandteil und muss nicht, wie in JavaScript, simuliert werden.
Also worum geht es?
Es geht nicht um Mathematik. Es geht um ein Verfahren mit dem wir eine Funktion spezialisieren können. Was das bedeutet möchte ich direkt an einem Beispiel aufzeigen.
const add = (n, m) => (n + m);
Die Funktion
add
erwartet zwei Argumente und addiert sie.
Ja, ein sehr simples Beispiel. Die selbe Funktion in der Curry-Schreibweise sieht so aus:const add = (n) => (m) => (n + m);
Nun ist
add
eine Funktion die ein einziges Argument n
entgegennimmt und eine neue Funktion liefert.
Diese wiederum nimmt das „fehlende“ m
als Argument und liefert das Ergebnis.Um den Vorteil zu beschreiben bleiben wir bei unserer
add
Funktion.
Nehmen wir an für jede Zahl in einem Array wollen wir 3 addieren.
Ohne Currying würde das so aussehen:1const add = (n, m) => (n + m);
2const result = [2, 3, 5, 7, 11].map((x) => add(3, x));
Mit der curryed Variante sehe es so aus:
1const add = (n) => (m) => (n + m);
2const result = [2, 3, 5, 7, 11].map(add(3));
In beiden Fällen liefert result das Array
[5, 6, 8, 10, 14]
.Wir haben also eine spezialisierte Funktion erstellt indem wir
add
nur das erste Argument,
die 3
, übergeben haben. Sollten wir diese spezialisierte Funktion häufiger benötigen
könnten wir sie auch vorab „fixieren“.1const add = (n) => (m) => (n + m);
2const add3 = add(3);
3const result = [2, 3, 5, 7, 11].map(add3);
Das Schöne an diesem Verfahren ist dass wir somit weniger Code schreiben. Und es wäre schnell erledigt wenn wir noch andere spezialisierte Funktionen benötigen:
/* ... */ const add3 = add(3); const add5 = add(5); /* ... */
Neben der gesparten Schreibarbeit entsteht auch eine gewisse Lesbarkeit.
.map(add3)
ist ziemlich aussagekräftig. Weiterhin ist es nicht mehr
möglich andere Dinge darin unterzubringen wie es in der Variante ohne Currying möglich wäre:const result = [2, 3, 5, 7, 11].map((x) => add(3, x - 1));
Sollte also ein unvorhergesehenes Verhalten auftreten kann man sicher sein dass es nicht aus der Zeile
.map(add3)
herrührt. Wenn dann muss man die Ausgangsfunktion,
in unserem Fall add
, prüfen.Dieses Zahlen-Array Beispiel scheint wenig Bezug zu echten Projekten zu haben. Deshalb kommen nun Anwendungsbeispiele die von meiner täglichen Arbeit inspiriert sind:
Arrays filtern
Angenommen wir haben ein
user
Array.
Jeder user hat einen anderen Tarif und wir benötigen an mehreren
Stellen eine Filtermöglichkeit um diese Daten zu unterscheiden:const hasPlan = (planId) => (user) => (user.plan === planId)
Wenn ich nun ein
user
Array filtern muss, nach einem bestimmten Tarif,
kann ich die selbe Funktion für jeden Tarif verwenden:users.filter(hasPlan('FREE')) users.filter(hasPlan('PRO'))
Spezialisierte reducer-dispatch Funktionen
Oft kommt es im React-Umfeld vor dass ich für verschiedene Texteingabefelder ähnliche Reducer-Actions feuern muss. Ohne Currying sieht das so aus:
1return <>
2 <input
3 onChange={(event) => dispatch({
4 type: A,
5 payload: event.target.value
6 })}
7 />
8 <input
9 onChange={(event) => dispatch({
10 type: B,
11 payload: event.target.value
12 })}
13 />
14 <input
15 onChange={(event) => dispatch({
16 type: C,
17 payload: event.target.value
18 })}
19 />
20</>
Mit Currying sieht es wie folgt aus:
1const dispatchWith = (type) => (event) => dispatch({
2 type,
3 payload: event.target.value
4})
5
6return <>
7 <input onChange={dispatchWith(A)} />
8 <input onChange={dispatchWith(B)} />
9 <input onChange={dispatchWith(C)} />
10</>
Rekursion mit einem unverändertem Parameter
Nicht selten kommt es vor dass ich prüfen muss ob in einer mehrdimensionale Datenstruktur ein Element mit einer bestimmten
id
enthalten ist.
Egal wie tief ich in den Daten suche, die zu suchende id
bleibt gleich.1const byId = (id) => (element) => {
2 return (id === element.id)
3 || (
4 ('childElements' in element)
5 && element.childElements.some(byId(id))
6 )
7}
Dies könnte unser Datensatz sein:
1const data = [ 2 { id: 'Nr1' }, 3 { id: 'Nr2', childElements: [ 4 { id: 'Nr2.1', childElements: [ 5 { id: 'Nr2.2' }, 6 { id: 'Nr2.3' }, 7 { id: 'Nr2.4' }, 8 ] } 9 ] }, 10 { id: 'Nr3' }, 11]
Und so wird das ganze genutzt:
const idExists = data.some(byId('Nr2.3')) // -> true
Dependency Injection
Am besten kann man eine Funktion testen wenn sie "pure" ist. Bedeutet dass man immer den selben Rückgabewert bekommt wenn man die selben Argumente hineingibt. Doch manche Funktion benötigt eben Dinge wie den aktuellen Timestamp. Sprich, der Rückgabewert der Funktion ist immer unterschiedlich da die Zeit natürlich weiter fortschreitet. Es gibt in Test-Frameworks die Möglichkeit einige globale Funktionen zu mocken. Doch wir können auch das Erstellen des Timestamps als Parameter in unsere Funktion einschleusen um im Testszenario das Mocken zu vereinfachen.
export const getTimestamp = () => Date.now();
export const getDayName = (getTimestamp, languageKey) => {
const currentTimestamp = getTimestamp();
/** do something with currentTimestamp & languageKey */
/** ... */
};
Dies hat zur Folge dass wir nun immer auch die Funktion getTimestamp als Parameter übergeben müssen um unsere Funktion zu nutzen.
Im Testfall ist dass sehr angenehm. Denn wir können nun fixierte Werte testen:
describe('getDayName', () => {
it('works!', () => {
const getTimestamp_MOCK = () => '1625310165520';
const actual = getDayName(getTimestamp_MOCK, 'de')
const expected = 'Samstag';
expect(actual).toEqual(expected);
});
});
Doch der Nachteil ist dass wir im Projektcode auch immer diese
getTimestamp
Funktion mitschleppen
und übergeben müssen. Eine Möglichkeit dass zu umgehen ist, du wirst es nicht glauben, Currying.
Zu diesem Zweck exportiere ich eine weitere Funktionen aus der Datei in der vorher nur
getDayName
exportiert wurde.export const getTimestamp = () => Date.now();
export const _getDayName = (getTimestamp) => (languageKey) => {
const currentTimestamp = getTimestamp();
/** do something with currentTimestamp & languageKey */
/** ... */
};
export const getDayName = _getDayName(getTimestamp);
In unserem Test würde ich nun _getDayName nutzen um unsere
getTimestamp_MOCK
genauso
zu nutzen wie schon abgebildet. Doch in meinem Produktivcode nutze ich die Funktion getDayName
.
Dieser Funktion muss ich nur noch den gewünschten languageKey
übergeben und alles andere ist schon gegeben.Abschließende Worte
Interessant ist für mich wie dieses Muster den Code verändert. Wie so manche Zeile zu einer menschenlesbaren Anweisung wird. Wie Funktionen verallgemeinert werden und ihr Wiederverwendbarkeit zunimmt.