Skip to content

Kapitel 03: Reines Glück durch reine Funktionen

Oh, zurück zur Reinheit!

Zunächst müssen wir das Konzept einer reinen Funktion klären.

Eine reine Funktion ist eine Funktion, die bei gleicher Eingabe stets dieselbe Ausgabe liefert und keine beobachtbaren Nebeneffekte aufweist.

Nehmen wir slice und splice. Beide Funktionen erfüllen denselben Zweck - wenn auch auf völlig unterschiedliche Weise. slice ist rein, weil es bei identischer Eingabe garantiert immer dasselbe Ergebnis liefert. splice hingegen verändert das ursprüngliche Array dauerhaft, was einen beobachtbaren Effekt darstellt.

js
const xs = [1,2,3,4,5];

// pure
xs.slice(0,3); // [1,2,3]

xs.slice(0,3); // [1,2,3]

xs.slice(0,3); // [1,2,3]


// impure
xs.splice(0,3); // [1,2,3]

xs.splice(0,3); // [4,5]

xs.splice(0,3); // []

In der funktionalen Programmierung lehnen wir schwerfällige Funktionen wie splice ab, die Daten verändern. Sie sind unbrauchbar, da wir nach zuverlässigen Funktionen streben, die stets dasselbe Ergebnis liefern - nicht Methoden, die wie splice Chaos hinterlassen.

Betrachten wir ein weiteres Beispiel:

js
// impure
let minimum = 21;
const checkAge = age => age >= minimum;

// pure
const checkAge = (age) => {
  const minimum = 21;
  return age >= minimum;
};

In der unreinen Variante hängt checkAge von der veränderlichen Variable minimum ab. Dies erhöht die kognitive Last, weil dadurch eine Abhängigkeit vom externen Systemzustand entsteht.

Obwohl hier trivial, gilt Zustandsabhängigkeit als Hauptverursacher von Systemkomplexität (http://curtclifton.net/papers/MoseleyMarks06a.pdf). Da checkAge je nach externen Faktoren variieren kann, verliert es nicht nur die Reinheit, sondern erschwert auch jede logische Analyse des Systems.

Die reine Version ist dagegen vollständig autark. Durch Unveränderlichkeit von minimum bleibt die Reinheit erhalten. Dazu erstellen wir ein Objekt, das eingefroren wird.

js
const immutableState = Object.freeze({ minimum: 21 });

Nebeneffekte können beinhalten...

Vertiefen wir unsere Intuition für „Nebeneffekte“. Was ist dieser berüchtigte Nebeneffekt in der Definition reiner Funktionen? Ein Effekt ist hier jede Aktion außer der eigentlichen Ergebnisberechnung.

Effekte sind nicht inhärent schlecht - wir werden sie später intensiv nutzen. Problematisch ist das Neben in Nebeneffekt. Wie stehendes Wasser Insekten brütet, bilden Nebeneffekte Brutstätten für Probleme in Programmen.

Ein Nebeneffekt ist jede Änderung des Systemzustands oder beobachtbare Interaktion mit der Außenwelt während der Berechnung.

Nebeneffekte umfassen unter anderem:

  • Änderungen am Dateisystem
  • Einfügen von Datenbankeinträgen
  • HTTP-Aufrufe tätigen
  • Datenmutationen
  • Bildschirmausgaben / Protokollierung
  • Erfassen von Benutzereingaben
  • DOM-Abfragen
  • Zugriff auf Systemzustände

Die Liste lässt sich fortsetzen. Jede externe Interaktion ist ein Nebeneffekt, was die Praktikabilität des Programmierens ohne sie fraglich erscheinen lässt. Die funktionale Philosophie sieht Nebeneffekte als Hauptursache für Fehlverhalten.

Es geht nicht um völligen Verzicht, sondern um kontrollierte Handhabung. Später mit Funktoren und Monaden lernen wir dies - vorerst trennen wir unreine Funktionen strikt von reinem Code.

Nebeneffekte disqualifizieren Funktionen als rein. Logisch: Reine Funktionen müssen per Definition bei gleicher Eingabe gleiche Ausgaben liefern - mit externen Effekten unmöglich.

Warum bestehen wir auf gleichen Ausgaben? Krägen hochklappen - zurück in die 8. Klasse!

Mathematik der 8. Klasse

Laut mathisfun.com:

Eine Funktion ist eine spezielle Beziehung zwischen Werten: Jeder Eingabewert liefert genau einen Ausgabewert.

Es handelt sich also um eine Relation zwischen Eingabe und Ausgabe. Jede Eingabe hat genau eine Ausgabe, die aber nicht eindeutig sein muss. Dieses Diagramm zeigt eine gültige Funktion von x nach y:

Funktionsmengen(https://www.mathsisfun.com/sets/function.html)

Im Gegensatz dazu zeigt dieses Diagramm eine Relation, die keine Funktion ist, da Eingabe 5 mehrere Ausgaben hat:

Keine Funktion(https://www.mathsisfun.com/sets/function.html)

Funktionen lassen sich als Paarmengen darstellen: [(1,2), (3,6), (5,10)] (hier: Verdopplungsfunktion).

Oder als Wertetabelle:

Input Output
1 2
2 4
3 6

Oder als Graph mit x-Eingabe und y-Ausgabe:

function graph

Implementierungsdetails sind irrelevant, wenn Eingaben Ausgaben bestimmen. Da Funktionen reine Abbildungen sind, könnte man sie als Objektliterale mit [] statt () schreiben.

js
const toLowerCase = {
  A: 'a',
  B: 'b',
  C: 'c',
  D: 'd',
  E: 'e',
  F: 'f',
};
toLowerCase['C']; // 'c'

const isPrime = {
  1: false,
  2: true,
  3: true,
  4: false,
  5: true,
  6: false,
};
isPrime[3]; // true

Natürlich rechnet man lieber als Listen zu schreiben - aber dies illustriert eine alternative Denkweise. („Was ist mit mehreren Argumenten?“ werden Sie fragen. Dafür nutzen wir Arrays oder Currying später.)

Die dramatische Enthüllung: Reine Funktionen sind mathematische Funktionen - das Fundament funktionaler Programmierung. Ihre Verwendung bringt enorme Vorteile:

Argumente für Reinheit

Zwischenspeicherung

Reine Funktionen lassen sich per Eingabe cachen, typischerweise durch Memoisierung:

js
const squareNumber = memoize(x => x * x);

squareNumber(4); // 16

squareNumber(4); // 16, returns cache for input 4

squareNumber(5); // 25

squareNumber(5); // 25, returns cache for input 5

Hier eine vereinfachte Implementierung (robuste Versionen existieren):

js
const memoize = (f) => {
  const cache = {};

  return (...args) => {
    const argStr = JSON.stringify(args);
    cache[argStr] = cache[argStr] || f(...args);
    return cache[argStr];
  };
};

Bemerkenswert: Man kann unreine Funktionen durch verzögerte Auswertung rein machen:

js
const pureHttpCall = memoize((url, params) => () => $.getJSON(url, params));

Wichtig: Der HTTP-Call erfolgt erst beim Aufruf. Die Funktion ist rein, da sie bei gleichen url/params stets dieselbe Call-Funktion liefert.

Unsere memoize-Funktion cached nicht die Ergebniswerte, sondern die erzeugten Funktionen.

Noch nicht direkt nützlich, aber später relevant. Entscheidend: Jede Funktion ist cachenbar - selbst zerstörerische.

Portabilität/Selbstdokumentation

Reine Funktionen sind autark. Alle Abhängigkeiten liegen offen da - keine versteckten Effekte. Dies ermöglicht:

js
// impure
const signUp = (attrs) => {
  const user = saveUser(attrs);
  welcomeUser(user);
};

// pure
const signUp = (Db, Email, attrs) => () => {
  const user = saveUser(Db, attrs);
  welcomeUser(Email, user);
};

Das Beispiel zeigt: Reine Funktionen deklarieren ihre Abhängigkeiten explizit. Allein die Signatur verrät die Nutzung von Db, Email und attrs.

Später lernen wir, solche Funktionen rein zu gestalten. Entscheidend: Reine Versionen sind transparenter als ihre heimtückischen unreinen Pendants.

Durch „Injecten“ von Abhängigkeiten als Parameter erhöhen wir Flexibilität. Bei Datenbankwechsel genügt Aufruf mit neuem Db. Wiederverwendung in anderen Apps erfordert nur Anpassung der Parameter.

In JavaScript-Kontext bedeutet Portabilität: Funktionen über Sockets senden oder in Web Workern ausführen. Ein mächtiges Feature.

Im Gegensatz zu imperativen Methoden mit impliziten Umgebungsabhängigkeiten laufen reine Funktionen überall.

Wann haben Sie zuletzt eine Methode in eine neue App kopiert? Joe Armstrong, Erlang-Erfinder, sagte: „Das Problem objektorientierter Sprachen ist ihr impliziter Kontext. Sie wollten eine Banane, bekamen aber einen Gorilla mit Banane... und den ganzen Dschungel.“

Testbarkeit

Reine Funktionen vereinfachen Tests: Kein Mocking von Payment-Gateways nötig. Einfach Eingabe liefern und Ausgabe prüfen.

Die funktionale Community entwickelt Testtools wie Quickcheck, die automatisch Eingaben generieren und Ausgabeeigenschaften prüfen. Sehr empfehlenswert!

Nachvollziehbarkeit

Der größte Vorteil: Referenzielle Transparenz. Codeabschnitte können durch ihre Ausgabewerte ersetzt werden, ohne Programmverhalten zu ändern.

Da reine Funktionen keine Nebeneffekte haben, beeinflussen sie das Programm nur durch ihre Ausgaben. Diese sind exakt vorhersagbar, was referenzielle Transparenz garantiert. Beispiel:

js
const { Map } = require('immutable');

// Aliases: p = player, a = attacker, t = target
const jobe = Map({ name: 'Jobe', hp: 20, team: 'red' });
const michael = Map({ name: 'Michael', hp: 20, team: 'green' });
const decrementHP = p => p.set('hp', p.get('hp') - 1);
const isSameTeam = (p1, p2) => p1.get('team') === p2.get('team');
const punch = (a, t) => (isSameTeam(a, t) ? t : decrementHP(t));

punch(jobe, michael); // Map({name:'Michael', hp:19, team: 'green'})

decrementHP, isSameTeam und punch sind rein und referenziell transparent. Mittels Gleichungsersatz können wir Code analysieren:

Zuerst integrieren wir isSameTeam:

js
const punch = (a, t) => (a.get('team') === t.get('team') ? t : decrementHP(t));

Da Daten immutable sind, ersetzen wir Teams durch ihre Werte:

js
const punch = (a, t) => ('red' === 'green' ? t : decrementHP(t));

Im Beispiel falsch, also entfernen wir den If-Zweig:

js
const punch = (a, t) => decrementHP(t);

Bei Inline-Ersetzung von decrementHP wird punch zur HP-Reduktion um 1:

js
const punch = (a, t) => t.set('hp', t.get('hp') - 1);

Diese Nachvollziehbarkeit ist ideal für Refactoring. Wir nutzten dies bereits bei der Möwen-Schwarm-Refaktorisierung mittels Addition/Multiplikation.

Parallele Ausführung

Krone aller Vorteile: Parallele Ausführung reiner Funktionen, da kein Shared Memory benötigt und Race Conditions per Definition ausgeschlossen sind.

Möglich in JS-Umgebungen mit Threads/Web Workern - aktuell aber wegen unreiner Funktionen selten genutzt.

Zusammenfassung

Reine Funktionen sind das Herzstück funktionaler Programmierung. Ab jetzt schreiben wir alle Funktionen rein. Hilfsmittel folgen später - vorerst trennen wir unreinen Code.

Ohne Tools ist reines Programmieren mühsam: Dauerndes Parameter-Jonglieren, ohne Zustände und Effekte. Lösung: Currying!

Kapitel 04: Currying