Die Daten kommen zum Schluss
September 18, 2019
typescript
Nicht viele Sprachen werden so verflucht wie Javascript. Dabei werden oft versucht bestehende Muster und Paradigmen aus bekannten Sprachen wie Java oder ähnliche in die Sprache zu integrieren, obwohl es an manchen Stellen praktischer wäre sich auf neue Paradigmen, wie beispielsweise der Funktionalen Programmierung einzulassen. Hier zeige ich einige Beispiele, wo Konzepte der Funktionalen Programmierung den Alltag mit Javascript erleichtern können.
In Javascript arbeiten wir oft mit der bekannten dot-notation
person.addresses[0].street.name
Die ist Standard und vor allem in der objektorientierten Programmierung bekannt. Was ist aber, wenn jeder dieser Werte undefined oder Null sein und das Programm entsprechend zur laufzeit crashen könnte? Vielleicht ein bisschen teriatary Operator Magie?
const hasStreetName = person.addresses ? person.addresses[0] ? addresses[0].street ? addresses[0].street.name ? true : false : false : false : false;
nicht schön, aber tut sein Job. Der geübte Javascript Veteran nutzt natürlich die Details der Sprache. Man nehme ein wenig “truhty” ein wenig “falsy” und ein bisschen &&-Magie…
const hasStreetName = !!(person.addresses && person.addresses[0] && person.addresses[0].street && person.addresses[0].street.name) // cast to boolean using !!()
Und schon erhalten wir einen wunderschönen, Null-safen Code. Jetzt nur noch warten bis es den “Safe Navigation”-Operator gibt und ordentlich refactoren. (Update: https://devblogs.microsoft.com/typescript/announcing-typescript-3-7-beta/)
Bis dahin können wir aber auch das klassische Mindset verlassen. Im Beispiel oben nehmen wir an, dass wir die Struktur der Daten kennen. Wir haben eine Person die eine vielleicht eine Addresse hat, die vielleicht eine Straße hat, die vielleicht einen Namen hat. In der Realität kennen wir die echten Daten aber gar nicht (Lars aus Oldenburg steht nirgendwo im Code). Wir kennen aber die Operationen, die wir auf den Daten ausführen wollen (dot-Notation), um ein Ergebnis zu erhalten. Aus der Brille betrachtet, könnten wir das Wort Operationen mit Funktionen austauschen und wir sind im Geschäft. Übersetzen können wir die “dot-Notaton” dann wohl in eine dot-Funktion…
const dot = (prop, obj) => obj[prop] ? obj[prop] : undefined;
dot("name", {name: "lars"}); // "lars"
dot("alter", {name: "lars", alter: 99}) // 99"
Diese Funktion können wir natürlich auch kombinieren:
const dot = (prop, obj) => obj[prop] ? obj[prop] : undefined;
const someData = {addresses: [{street: {name: "MyStreet"}}]};
const someDataMissing = {addresses: null};
dot('addresses', someDataMissing) // undefined
dot('addresses', someData) // [{street: {name: "MyStreet"}}]
dot(0, dot('addresses', someData)) // {street: {name: "MyStreet"}}
dot('street', dot(0, dot('addresses', someData))) // {name: MyStreet}
dot('name', dot('street', dot(0, dot('addresses', someData)))) // "MyStreet"
Ziemlich tief verschachtelt. Durch eine Funktion die sich “pipe” nennt können wir den Ausdruck abflachen. Pipe nimmt Funktionen an, die Daten transformieren und “pipet” das Ergebnis derer in die nächste Funktion.
const toUppercase = someString => someString.toUpperCase();
const parseInt = someString => parseInt(someString, 10);
const addFive = someNumber => someNumber + 5;
const parseStringAndAddFive = pipe(
// "5"
parseInt, // 5
addFive // 5 + 5
)("5") // 10
Das ist das gleiche, wie man es in der objektorientierung durch Method Chaining kennt.
person
.getAddress()
.getStreet()
.getName();
).)
oder aus der unix shell
echo "hello" | grep "he"
oder halt in der Mathematik ;)
const streetName = name(street(address(person)))
console.log(streetName(person))
// ===
const streetNamePipe = pipe(address, street, name)
console.log(streetNamePipe(person))
Grundsätzlich stellt sich jetzt die Frage, was am ersten Ansatz falsch sein könnte. Spannend wird es, wenn Funktionen konfiguriert werden können. Mittels currying haben wir dann ein Ass im Ärmel.
Nehmen wir dazu zunächst einfach folgende Funktion an
const add = (a, b) => a+b
so können wir diese “currien”. Das bedeutet soviel wie: Jeder Parameter gibt eine Funktion zurück, die den nächsten Parameter entgegen nimmt.
const add = (a, b) => a + b;
// Add liefert uns eine Zahl 5 add 5 = 10
const add = (a) => (b) => a + b;
// Add liefert eine Funktion, die nur den nächsten Parameter annimmt.
const addFive = add(5) // ========== (b) => 5 + b;
// addFive ist eine Funktion, KEINE Zahl...
Abgesehen davon, dass wir damit Fachlichkeiten modellieren können:
const toMinutes = multiply(60) // (hours) => hours * 60;
Kann man damit super Funktionen zusammenstecken und unser dot-Beispiel verschönern. Also currien wir ebendieses.
const dot = (prop) => (obj) => obj[prop] ? obj[prop] : undefined;
Kombinieren wir jetzt das Beispiel der pipe mit unser gecurryeten Funktion, erhalten wir:
const name = pipe(
dot('addresses'), // (objekt) => objekt.addresses
dot(0), // (objekt) => objekt[0]
dot('street'), // (objekt) => objekt.street
dot('name') // objekt => objekt.name
);
Geben wir der “Pipeline” jetzt zum Schluss Futter, spuckt sie schon das Ergebnis aus.
const lars = {addresses: [{street: {name: "ABC"}}]}
const larsStreetName = pipe(
dot('addresses'), // (lars) => lars.addresses
dot(0), // (larsAddressen) => larsAddressen[0]
dot('street'), // (larsErsteAdresse) => larsErsteAdresse.street
dot('name') // larsErsteAdresseStrasse => larsErsteAdresse.name
)(lars)
Das ganze können wir noch immer weiter auf die Spitze composen … Deshalb sagt man auch “aus kleinen Funktionen” immer größere zusammenstecken.
const apply = (curr, acc) => acc(curr);
const path = propArr => data => pipe(map(dot), reduce(apply, data));
const name = path(["adresses", 0, "street", "name"])
Damit die Funktionen (prop, pipe, path) nicht alle selbst implementiert werden müssen, gibt es glücklicherweise die Bibliothek Ramda, die so ziemlich alle Funktionen hat, die man zum entwickeln benötigt.
Ramda? Das kann lodash doch auch!
Ja. Oder … kann es? Was ist denn, wenn wir alle Namen der Straßen bitte in Uppercase haben wollen? ({name: “abc”} => {name: “ABC”})
import {pipe, toUppercase} from 'ramda';
const streetName = path(['street', 'name'])
const streetUppercase = pipe(streetName, toUpper);
const uppercaseStreetName = pipe(path(['addresses']), map(streetUppercase))
console.log(uppercaseStreetName(lars))
Das war einfach. Wie sieht es mit lodash aus?
import _ from 'lodash'
const streetName = _.property(["street", "name"]);
const streetNameUppercase = address => _.toUpper(streetName(address))
const uppercaseStreetName = (person) => _.map(_.property("addresses")(person), streetNameUppercase)
console.log(uppercaseStreetName(lars))
Schon schwieriger, und das obwohl property gecurried ist. Um den Punkt deutlich zu machen…
import {take, sortBy, property, map} from 'lodash'
type Player = { score: number, name: string }
const worstThreeScores = (scorer: Player[]) => {
return take(
map(
sortBy(scorer, property("score")),
property("score")
), 3);
}
console.log(worstThreeScores(allScorer))
Von einer Liste von Spielern, werden die 3 schlechtesten Punkte ausgewählt. Es fällt auf, dass wir ein Player[] argument annehmen und das immer wieder benutzen müssen, um die Funktionen, die diese als ersten Parameter annehmen entsprechend anzureichen. Schauen wir uns mal an, wie wir das mit Ramda machen würden …
import {sortBy, pipe, map, prop, take} from 'ramda'
type Player = { score: number, name: string }
const worstThreeScores = pipe(
sortBy(prop("score")),
map(prop("score")),
take(3)
)
console.log(worstThreeScores(allPlayers))
Warum geht das ohne Paramter? Daten, die als erster Parameter angenommen werden unterbrechen den Datenfluss. Man stelle sich ein ein Domino Spiel vor und muss alle 2 Steine die Steine wieder anschubsen. Sehr mühselig. Wir sehen bei den lodash Beispielen immer wieder, wie die Funktionen durch die Daten angereicht werden mussten. Das hat so ziemlich die gleichen Effekt. Warum haben die Ramda Beispiele das Problem nicht? Weil die Daten der letzte Parameter sind. Dadurch kann mit einer prise Currying die komplette Datenpipeline gestaltet werden. Die Daten stoßen das ganze am Ende an. Das Ganze ist auch “tacit”-Programming oder “point-free-style” bekannt. Also merken: Datensubjekte sind der letzte Parameter!