post

Funktionale Stile im Alltag

Warum funktionaler Stil im Team-Alltag 2015 nicht Haskell bedeutet, sondern die Disziplin, reine Funktionen von I/O zu trennen – und wie ES2015 das endlich bequem macht.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (12 Min.)

MP3 herunterladen

Wenn Geschäftslogik und Nebenwirkungen ungetrennt im selben Funktionsrumpf stehen, hat das eine sehr konkrete Konsequenz: Jeder Test braucht eine Datenbank. Wer prüfen will, ob eine Rechnung als überfällig gilt, muss vorher Testdaten einspielen, einen Mailer durch eine Attrappe ersetzen und hinterher alles wieder aufräumen. Die Testsuite läuft Minuten statt Sekunden, also läuft sie seltener, also traut sich niemand mehr an genau die Stellen, die eine Änderung am dringendsten nötig hätten. Das ist kein Werkzeugproblem und kein Framework-Problem – es ist ein Stilproblem.

Funktionale Programmierung gilt vielen Kollegen immer noch als akademische Veranstaltung: Haskell, Monaden, Typtheorie. Was ich im Projektalltag darunter verstehe, ist deutlich bescheidener und gleichzeitig wirksamer: reine Funktionen als Standardbaustein, Datenfluss statt Zustandsmutation, map/filter/reduce statt Schleifenrümpfen mit Nebenwirkungen. Keine neue Sprache, keine neue Laufzeitumgebung – eine Disziplin in der Sprache, die ohnehin schon im Einsatz ist.

Reine Funktionen als Standardbaustein

Eine reine Funktion ist schnell erklärt: gleiche Eingabe, gleiche Ausgabe, keine beobachtbare Nebenwirkung. Sie liest nicht die Uhr, schreibt nicht in die Datenbank, verändert keine Variablen außerhalb ihres Rumpfs. Das klingt banal, ist im gewachsenen Code aber die Ausnahme. Typischer ist eine Funktion, die Daten lädt, filtert, umrechnet, speichert und nebenbei noch eine E-Mail verschickt – fünf Verantwortungen in dreißig Zeilen.

Anfang des Jahres habe ich mir Crista Lopes' Sammlung exercises-in-programming-style geforkt: dieselbe kleine Aufgabe – eine Worthäufigkeitsanalyse – in Dutzenden Programmierstilen, jeder Stil ein eigener Ordner mit Programm und Beschreibung. Beim Durcharbeiten ist mir etwas aufgefallen, das mir vorher so klar nicht war: Stil ist eine Entscheidung, kein Sprachmerkmal. Dieselbe Aufgabe lässt sich in Python monolithisch, objektorientiert oder als Pipeline reiner Funktionen schreiben. Die Sprache verhindert nichts davon und erzwingt nichts davon. Das gilt für JavaScript und C# genauso.

Die Disziplin besteht darin, die reine Funktion zum Normalfall zu machen. Nicht: „Wo könnte ich mal etwas Funktionales einstreuen?", sondern umgekehrt: Jede Funktion ist rein, bis ein Grund dagegen spricht. Und die Gründe dagegen – I/O, Zeit, Zufall, DOM, Datenbank – sammeln sich dann an wenigen, benannten Stellen statt überall.

Was ES2015 daran ändert

Seit Juni ist ECMAScript 2015 verabschiedet – die Spezifikation ist final, und Babel 5 übersetzt den neuen Stand heute schon auf ES5 herunter. Node 0.12 und io.js beherrschen Teile davon nativ, für den Rest bleibt der Transpiler im Build. Für den funktionalen Stil ist das mehr als Kosmetik, denn drei Neuerungen senken genau die Schreibkosten, an denen dieser Stil bisher gescheitert ist:

  • Arrow Functions machen kleine Funktionen so billig, dass man sie tatsächlich schreibt – x => x.amount statt eines vierzeiligen function-Blocks mit return.
  • const dokumentiert im Code, dass eine Bindung nicht neu zugewiesen wird – die Leserichtung dreht sich: Mutation wird zur markierten Ausnahme.
  • Destrukturierung zerlegt Datenstrukturen ohne Hilfsvariablen, was Funktionen über schlichten Objekten erst angenehm macht.

Der Transpiler ist dabei eine ehrliche Zusatzkosten-Position: Der Build wird um einen Schritt länger, Sourcemaps müssen stimmen, und wer im Browser debuggt, schaut gelegentlich auf generierten ES5-Code statt auf das, was er geschrieben hat. In unserem Fall war das den Tausch wert – aber es gehört auf den Tisch, bevor das Team zustimmt, nicht danach.

Vorher war funktionaler Stil in JavaScript möglich, aber syntaktisch teuer. Ein function (invoice) { return !invoice.paid; } an jeder Filterstelle ermüdet, und was ermüdet, unterbleibt. Mit Pfeilfunktionen kippt die Abwägung: Die kompakte Form ist jetzt auch die disziplinierte Form. Ich habe in einem laufenden Projekt erlebt, wie allein die Umstellung auf Babel die Zahl kleiner Hilfsfunktionen im Team vervielfacht hat – nicht weil jemand es angeordnet hätte, sondern weil es plötzlich der bequemste Weg war.

Vom Schleifenrumpf zur Datenleitung

Konkret wird der Unterschied an einem Stück Code, wie es in jedem Backoffice-System steht: Aus einer Liste von Rechnungen sollen die überfälligen ermittelt, Mahnungen erzeugt und die offene Summe berechnet werden. Die verbreitete Form sieht so aus:

function processInvoices(invoices, db, mailer) {
  let total = 0;
  for (let i = 0; i < invoices.length; i++) {
    const invoice = invoices[i];
    if (!invoice.paid && invoice.dueDate < Date.now()) {
      total += invoice.amount;
      db.markAsOverdue(invoice.id);
      mailer.sendReminder(invoice.customerEmail, invoice.amount);
    }
  }
  return total;
}

Der Code funktioniert, und genau das macht ihn langlebig – niemand fasst eine Funktion an, die seit zwei Jahren unauffällig ihren Dienst tut. Trotzdem vermischt der Schleifenrumpf vier Dinge: die fachliche Regel (wann ist eine Rechnung überfällig?), die Aggregation (Summe), den Datenbankzugriff und den Mailversand. Dazu liest die Funktion mit Date.now() heimlich die Uhr – derselbe Aufruf liefert um Mitternacht ein anderes Ergebnis als um 23

. Testbar ist das nur mit echter Datenbank, echtem Mailer-Ersatz und eingefrorener Zeit.

Dieselbe Logik als Datenfluss, mit ES2015:

const isOverdue = (today) => (invoice) =>
  !invoice.paid && invoice.dueDate < today;

const toReminder = (invoice) => ({
  invoiceId: invoice.id,
  email: invoice.customerEmail,
  amount: invoice.amount
});

const totalAmount = (invoices) =>
  invoices.reduce((sum, { amount }) => sum + amount, 0);

const overdueReminders = (today, invoices) => {
  const overdue = invoices.filter(isOverdue(today));
  return {
    reminders: overdue.map(toReminder),
    total: totalAmount(overdue)
  };
};

Vier reine Funktionen. Die Zeit kommt als Parameter herein statt aus der Uhr, die fachliche Regel steht isoliert in isOverdue, und overdueReminders beschreibt nur noch, was aus den Daten werden soll – nicht, wie eine Laufvariable hochzuzählen ist. Der Test dafür ist ein Array-Literal und eine Gleichheitsprüfung, keine Datenbank weit und breit. Und wer den Code in einem halben Jahr liest, sieht die Regel auf einen Blick, statt sie aus einem Schleifenrumpf herauszupräparieren.

Der unreine Rand

Die ehrliche Grenze gehört dazu: db.markAsOverdue und mailer.sendReminder sind nicht verschwunden. I/O, DOM und Datenbank bleiben unrein – eine Anwendung, die nichts liest und nichts schreibt, ist nutzlos. Der Punkt ist nicht, Nebenwirkungen zu verbieten, sondern sie an den Rand zu schieben, wo sie sichtbar und dünn sind.

flowchart LR
  DB[(Datenbank)] -->|Rechnungen laden| Edge[Unreiner Rand<br/>I/O, Uhr, Mailer]
  Edge -->|Daten hinein| Core[Reiner Kern<br/>isOverdue, toReminder,<br/>totalAmount]
  Core -->|Ergebnis heraus| Edge
  Edge -->|Mahnungen senden| Mail[Mailserver]

Am Rand sieht der Code dann so aus – Promises sind mit ES2015 Teil der Sprache:

const today = Date.now();

db.loadInvoices()
  .then((invoices) => overdueReminders(today, invoices))
  .then(({ reminders, total }) => {
    reminders.forEach((reminder) => mailer.sendReminder(reminder));
    log.info(`sent ${reminders.length} reminders, open amount: ${total}`);
  });

Dieser Rand ist bewusst langweilig: laden, reine Funktion aufrufen, Ergebnis wegschreiben. Er wird kaum je einzeln getestet, weil er kaum Logik enthält – die Logik steht im Kern, und der Kern kennt weder Datenbank noch Mailer. In einem Projekt haben wir genau nach diesem Schnitt die Integrationstests von einer halben Stunde auf eine Handvoll Randfälle reduziert; alles Fachliche lief als reiner Unit-Test in Sekunden.

Der Blick aus der .NET-Welt

Wem das als JavaScript-Mode erscheint, dem hilft ein Blick in die .NET-Welt, aus der ich ebenfalls komme. Dort heißt derselbe Gedanke seit Jahren LINQ und regt niemanden mehr auf:

var overdue = invoices
    .Where(invoice => !invoice.Paid && invoice.DueDate < today)
    .ToList();

var reminders = overdue.Select(invoice => new Reminder
{
    InvoiceId = invoice.Id,
    Email = invoice.CustomerEmail,
    Amount = invoice.Amount
});

var total = overdue.Sum(invoice => invoice.Amount);

Where, Select, Sum – das sind filter, map und ein spezialisiertes reduce. In C#-Teams schreibt das seit Jahren jeder, ohne es funktionale Programmierung zu nennen; es ist schlicht die normale Art, mit Auflistungen zu arbeiten. Wer eine foreach-Schleife mit Laufsumme in ein Review bringt, bekommt dort die Rückfrage, warum es kein LINQ ist. Diese Beobachtung nimmt dem Thema die Ideologie: Es geht nicht um ein Lager, sondern um eine Ausdrucksform, die sich in einer sehr pragmatischen Umgebung längst durchgesetzt hat – JavaScript zieht mit ES2015 gerade nach.

Underscore, Lodash, Ramda

Für die Lücken der Standardbibliothek gibt es Werkzeugkästen. Underscore war jahrelang gesetzt, Lodash hat es in Version 3 praktisch abgelöst – gleiche Idee, mehr Funktionen, und verkettete Aufrufe werden seit Lodash 3 lazy ausgewertet, sodass lange Ketten nicht bei jedem Schritt ein Zwischen-Array erzeugen müssen. Ramda geht seit gut einem Jahr einen Schritt weiter: Alle Funktionen sind automatisch gecurryt, und die Daten kommen als letztes Argument, sodass sich Verarbeitungsschritte zu neuen Funktionen zusammenstecken lassen:

const openAmountOf = R.pipe(
  R.filter(isOverdue(today)),
  R.map(R.prop('amount')),
  R.sum
);

const total = openAmountOf(invoices);

openAmountOf ist eine benannte, wiederverwendbare Pipeline – kein Aufruf, sondern eine Definition. Das ist elegant, und genau hier beginnt die Reibung.

Wo es im Team reibt

Der funktionale Stil hat im Team-Alltag echte Kosten, und wer sie verschweigt, verliert die Skeptiker zu Recht. Drei Punkte begegnen mir regelmäßig:

  • Debugging von Ketten ist mühsamer als Debugging einer Schleife. In einem for-Rumpf setzt man einen Haltepunkt und sieht alle lokalen Variablen; in einer pipe-Kette aus anonymen Funktionen zeigt der Stacktrace kryptische Namen, und für einen Blick auf Zwischenwerte muss man die Kette aufbrechen oder eine tap-Funktion einschieben.
  • Point-free-Schreibweise kippt schnell in Selbstzweck. R.pipe(R.filter(...), R.map(R.prop('amount')), R.sum) liest ein geübtes Auge flüssig – drei weitere Kompositionsebenen später liest es niemand mehr, auch der Autor nicht. Im Review hat sich bei uns eine einfache Frage bewährt: Kann der Kollege am Nachbartisch die Zeile ohne Erklärung laut vorlesen und sagen, was sie tut?
  • Das Performance-Argument kommt zuverlässig und ist meistens ein Mythos. Ja, filter und map erzeugen Zwischen-Arrays, und eine handgeschriebene Schleife ist auf einer Million Elementen schneller. Nur verarbeiten die wenigsten Codestellen eine Million Elemente – sie verarbeiten dreißig Rechnungen, und der eigentliche Engpass ist der Datenbankaufruf daneben. Erst messen, dann optimieren; die zwei heißen Pfade, die es wirklich betrifft, dürfen gern eine kommentierte Schleife bleiben.

Dazu kommt eine soziale Komponente: Ein Team, in dem zwei Leute Ramda lieben und fünf es nicht lesen können, hat mit dem Stil nichts gewonnen. Der gemeinsame Nenner – map, filter, reduce, reine Funktionen, dünner Rand – trägt weiter als die vollendete Komposition. Die Bibliothek ist verhandelbar, die Disziplin nicht.

Ausblick: Ströme

Wer die Trennung von Kern und Rand einmal verinnerlicht hat, findet die nächste Stufe fast von selbst. RxJS behandelt Ereignisse – Klicks, WebSocket-Nachrichten, Timer – als Auflistungen über die Zeit, mit demselben Vokabular: map, filter, reduce beziehungsweise scan auf Strömen statt auf Arrays. Die reinen Funktionen aus dem Kern lassen sich unverändert einsetzen; nur der unreine Rand spricht dann eben Observables statt Promises. Für Oberflächen mit vielen gleichzeitigen Ereignisquellen ist das ein spürbarer Gewinn – aber das ist ein eigener Artikel.

Unterm Strich

Funktionale Programmierung im Alltag von 2015 heißt für mich nicht, die Sprache zu wechseln oder dem Team Kategorientheorie zuzumuten. Es heißt, drei Gewohnheiten zu pflegen: Funktionen sind rein, bis ein Grund dagegen spricht. Daten fließen durch map, filter und reduce, statt in Schleifenrümpfen mutiert zu werden. Und alles Unreine – Uhr, Datenbank, DOM, Netzwerk – steht gesammelt an einem dünnen, langweiligen Rand. ES2015 macht diese Gewohnheiten zum ersten Mal wirklich bequem, LINQ zeigt seit Jahren, dass sie mainstream-tauglich sind, und die Testsuite dankt es sofort: Der Teil des Systems, in dem die Fehler wehtun, braucht plötzlich keine Datenbank mehr. Dafür muss niemand Haskell lernen – nur konsequent bleiben, wenn die nächste Schleife mit Laufsumme im Review auftaucht.

Weiterführende Quellen

Kommentare