Konzepte statt Hypes

In der JavaScript-Szene erscheinen regelmäßig neue und aktualisierte Module. Das bringt das Ökosystem voran, kann allerdings zugleich schaden. Statt ständig den neuesten Trends hinterher zu jagen, empfiehlt sich ein Schulterblick in die Vergangenheit.

In Pocket speichern vorlesen Druckansicht 19 Kommentare lesen
Konzepte statt Hypes
Lesezeit: 14 Min.
Von
  • Golo Roden
Inhaltsverzeichnis

Die npm-Registry beherbergt derzeit über 400 000 Module. Während den meisten davon nicht allzu viel mediale Aufmerksamkeit zuteil wird, haben einige sehr viel Wirbel verursacht. Dazu zählen unter anderem Lodash, Immutable.js und React.

Die drei Module stehen exemplarisch für zahlreiche weitere, denen allen etwas gemein ist: Sie sind technisch und konzeptionell brillant, lösen bei vielen Entwicklern zunächst aber Unbehagen aus, da etwa die langfristige Relevanz unklar ist.

Einerseits ist das nicht verwunderlich. Das Tempo, in dem Entwickler neue Module veröffentlichen und alte aktualisieren, ist gerade in der JavaScript-Szene enorm hoch. Um nicht Gefahr zu laufen, abgehängt zu werden, bleibt gefühlt keine andere Option, als halbwegs zeitnah jedem Trend zu folgen. Andererseits ist es erschreckend, denn betrachtet man Lodash, Immutable.js oder React genauer, fällt auf, dass sie letztlich wenig Neues enthalten. Im Großen und Ganzen greifen sie lediglich auf diverse Konzepte funktionaler Programmierung zurück.

Dass Kenntnis Letzterer in Teilen der Entwicklerszene nicht genügend weit verbreitet ist, hängt sicherlich unter anderem mit der Blütezeit der objektorientierten Programmiersprachen gegen Ende des 20. und Beginn des 21. Jahrhunderts zusammen. Für lange Zeit waren die drei einflussreichsten Sprachen C++, Java und C#. Alle drei verfolgen das gleiche Paradigma und sind einander daher verhältnismäßig ähnlich.

Um neue Bibliotheken oder Frameworks besser einschätzen zu können, hilft folglich ein Blick in die Vergangenheit oder generell eine Auseinandersetzung mit grundlegenden Konzepten der Informatik, denn obwohl die genannten Tools gut darin sind, sie zu implementieren, können sie Entwicklern nicht die Entscheidung abnehmen, ob sich der Einsatz für ein spezielles Projekt lohnt.

Sollte an der Stelle die Frage aufkommen, warum gerade jetzt Konzepte funktionaler Programmierung wiederentdeckt werden, hilft ein kritischer Blick auf die Sprachen, die dem Paradigma der Objektorientierung folgen. Dabei fällt auf, dass sie viele der von ihr versprochenen Aspekte nicht einhalten konnte. Insbesondere die angekündigte Wiederverwendbarkeit hat nicht in dem Maß stattgefunden, wie es zunächst vorhergesagt wurde. Der englische Informatiker Joe Armstrong, der die Sprache Erlang entwickelt hat, beschrieb den Umstand folgendermaßen:

"The problem with object-oriented languages is they've got all this implicit
environment that they carry around with them. You wanted a banana but what you
got was a gorilla holding the banana and the entire jungle."

Hinzu kommt ein weiteres gravierendes Problem. Als Softwareentwickler konnte man sich früher darauf verlassen, dass die eigene Anwendung mit der nächsten Prozessorgeneration schneller ausgeführt werden würde. Software wurde quasi automatisch schneller. Heute gilt das nicht mehr, da die Taktfrequenz der Prozessoren, wenn überhaupt, nur noch unmerklich wächst. Stattdessen steigt heute die Zahl der verfügbaren Kerne, doch das nützt einer Anwendung erst einmal nichts. Um mehrere Kerne oder gar mehrere Prozessoren nutzen zu können, muss sich der Code einer Anwendung parallelisieren lassen.

Das ist in der Objektorientierung zum Teil schwer umzusetzen, da mehrere Threads den Zustand eines Objekts beeinflussen können, was spezielle Mechanismen zur Synchronisation erforderlich macht. Selbst wenn man äußerst akribisch vorgeht, führt das trotzdem rasch zu Konflikten wie Race-Conditions oder Deadlocks.

In der funktionalen Programmierung lassen sich diese Probleme einfach vermeiden, indem man schlichtweg ohne Zustand arbeitet. Funktionen verwenden stattdessen stets nur lokale Variablen. Dadurch, dass keine Rücksicht auf die Zustände anderer Programmteile zu nehmen ist, fällt die Parallelisierung mit ihr wesentlich leichter.

Doch das alleine genügt noch nicht. Außerdem ist nämlich darauf zu achten, die technischen und fachlichen Belange des Codes voneinander zu trennen. Möchten Entwickler beispielsweise eine Funktion schreiben, die eine Liste von Zahlen quadriert, lässt sich das auf den ersten Blick rasch mit dem folgenden Code bewerkstelligen:

const getSquares = function (numbers) {
const squares = [];

for (let i = 0; i < numbers.length; i++) {
squares.push(numbers[i] ** 2);
}

return squares;
};

Obwohl die einzelnen Schleifeniterationen vollständig unabhängig voneinander sind und die Schleife daher ein perfekter Kandidat für Parallelisierung wäre, ist das jedoch nicht ohne weiteres möglich. Das liegt daran, dass der Code explizit vorgibt, wie das Array zu durchlaufen ist. Den Umstand, dass die einzelnen Iterationen unabhängig voneinander sind, drückt er nicht aus und es ist für die Laufzeitumgebung äußerst schwierig zu erkennen.

Abhilfe würde ein höheres Abstraktionsniveau schaffen, das das Durchlaufen der Schleife vom eigentlichen Schleifenrumpf trennt. Da sich eine solche Abstraktion durch das Einführen einer Funktion umsetzen lässt, ist ihr jedoch der Schleifenrumpf als auszuführender Code zu übergeben. Es ist daher erforderlich, Funktionen als Parameter an andere Funktionen übergeben zu können.

Vergleicht man das vorige Beispiel mit dem folgenden Code, fällt auf, dass er nicht nur weitaus kürzer ist, sondern auch aussagekräftiger, da auf den ersten Blick klar ist, dass die map-Funktion einzelne Zahlen auf andere Zahlen abbildet:

const getSquares = function (numbers) {
return numbers.map(n => n ** 2);
};

Damit das funktioniert, müssen Funktionen "Bürger erster Klasse" in der gewählten Programmiersprache sein. Das Besondere an der im obigen Codeauszug eingeführten map-Funktion ist übrigens nicht nur, dass sie ein höheres Abstraktionsniveau bietet, sondern dass sie keine explizite Implementierung mehr ausdrückt: Ihre Verwendung gibt lediglich an, was zu erreichen ist, aber nicht wie. Daher obliegt es nun der Laufzeitumgebung, die map-Funktion gegebenenfalls zu parallelisieren. Der US-amerikanische Unternehmer Joel Spolsky hat das Beispiel in seinem äußerst lesenswerten Blogeintrag "Can Your Programming Language Do This?" aufgegriffen.

Will man herausfinden, warum sich die map-Funktion im Gegensatz zur for-Schleife problemlos parallelisieren lässt, ist das höhere Abstraktionsniveau nur ein Teil der Antwort. Der wichtigste Aspekt ist, dass map im Gegensatz zu der explizit ausformulierten Schleife keine Seiteneffekte hat. Als Seiteneffekt bezeichnet man beim Programmieren die Veränderung des Zustands einer Anwendung.

Zusammenfassend lässt sich festhalten, dass Code immer dann gut parallelisiert werden kann, wenn er keine Seiteneffekte aufweist. Das lässt sich an einem weiteren, sehr einfachen Beispiel zeigen. Gegeben sei der folgende Ausdruck:

const x = (23 * 5) + (42 * 7);

Offensichtlich haben die beiden geklammerten Terme weder Seiteneffekte noch Abhängigkeiten voneinander. Daher ist es problemlos möglich, sie unabhängig voneinander zu berechnen. Erst das Bilden der Summe verbindet die beiden Terme, weshalb dafür die Evaluation der beiden Einzelteile abgeschlossen sein muss.

Gelingt es, Code ausschließlich aus Ausdrücken aufzubauen, die keinerlei Seiteneffekte aufweisen, lässt er sich einfach parallelisieren. Dem im Weg stehen leider sämtliche Anweisungen einer Programmiersprache, deren Ziel ein Seiteneffekt ist: seien es Variablenzuweisungen oder Bildschirmausgaben.

Nimmt man das alles zusammen, lässt sich neben der fehlenden Wiederverwendbarkeit noch ein zweites Problem der Objektorientierung erkennen. Kommen Objekte aus mehreren Threads parallel zum Einsatz, kann es zu gleichzeitigen Lese- und Schreibzugriffen kommen. Das wirft eine Reihe von Synchronisationsproblemen auf. Interessanterweise verfügen viele objektorientierte Programmiersprachen über entsprechende Schlüsselwörter, um den Entwickler bei der Synchronisation zu unterstützen. So kennt Java beispielsweise synchronized, C# hingegen lock und volatile.

Die eigentliche Lösung des Problems wäre es, keine Zustandsänderungen an Objekten zuzulassen. Dazu wäre es erforderlich, jede Methode so zu schreiben, dass sie eine neue Instanz zurückgibt statt die bestehende zu verändern. Das ist möglich, in den meisten Sprachen aber aufwändig. Solche unveränderlichen Objekte bezeichnet man als "immutable". Das folgende Codebeispiel zeigt, wie sich ein Objekt zum Abbilden von Wahrscheinlichkeiten implementieren lässt, ohne dass Seiteneffekte auftreten:

const Probability = function (probability) {
if (probability < 0) {
throw new Error('Invalid probability.');
}
if (probability > 1) {
throw new Error('Invalid probability.');
}

this.probability = probability;
};

Probability.prototype.equals = function (other) {
const epsilon = 0.01;

return Math.abs(this.probability - other.probability) < epsilon;
};

Probability.prototype.inverse = function () {
return new Probability(1 - this.probability);
};

Probability.prototype.combined = function (other) {
return new Probability(this.probability * other.probability);
};

Probability.prototype.either = function (other) {
return new Probability(
(this.probability + other.probability) -
(this.probability * other.probability)
);
};

Nimmt man das alles zusammen, stellt man fest, dass die genannten Konzepte nicht neu sind. Funktionale Programmierung, die map-Funktion, Funktionen als Bürger erster Klasse, Abstraktion, Unveränderlichkeit – das alles gibt es seit Jahrzehnten. Selbst die 1958 erschienene Sprache Lisp enthält all diese Konzepte. So lautet das Pendant zur JavaScript-Funktion

const getSquares = function (numbers) {
return numbers.map(n => n ** 2);
};

in Lisp

(defun get-squares (numbers)
(mapcar
(lambda (number) (expt number 2))
numbers))

wobei mapcar der Funktion map und expt dem **-Operator entspricht. Der Rest dürfte selbsterklärend sein.

Auch das Verwenden unveränderlicher Typen ist in Lisp vorgesehen. So gibt es beispielsweise zwei Funktionen zum Verknüpfen von Listen, append und nconc. Während Ersteres eine neue Liste zurückgibt und die ursprünglichen nicht verändert, modifiziert Letzteres diese. Die Dokumentation der nconc-Funktion weist darauf explizit hin, und das sogar unter dem Stichwort "Side Effects":

**Side Effects:**
The *lists* are modified rather than copied.

Bewertet man die eingangs erwähnten Module vor diesem Hintergrund, verlieren sie viel von ihrer vermeintlichen Magie. Es handelt sich bei Lodash, Immutable.js und React nämlich gerade nicht um kurzlebige Hypes, sondern um vernünftige Implementierungen uralter Konzepte. Das beantwortet zugleich die Frage, wie langlebig die Module sind und ob sie tatsächlich benötigt werden. Es mag sein, dass die konkrete Implementierung über kurz oder lang durch eine andere ersetzt wird, aber die Essenz der Module gehört zu den Grundlagen der Informatik.

Vergleicht man mit dem Wissen nun beispielsweise React und AngularJS, erkennt man bei React rasch nicht nur die technische, sondern auch die konzeptionelle Raffinesse. Es setzt nicht nur weitaus stärker auf den JavaScript-Standard und erfordert deshalb weitaus weniger Einarbeitungszeit in proprietäre, Framework-spezifische Aspekte, sondern nutzt auch die Möglichkeiten der funktionalen Programmierung viel besser aus.

An die Stelle nicht standardisierter Eigenschaften wie ng-repeat und *ng-for tritt die map-Funktion aus JavaScript. Der Ansatz ist weitaus eleganter als die Definition eigener Konstrukte, die letztlich doch nur dem Versuch entspringen, das Gleiche auszudrücken wie die map-Funktion.

Ein weiterer wichtiger Aspekt, der in allen drei Modulen eine Rolle spielt, ist die Frage nach der Identität beziehungsweise der Gleichheit von Objekten. Ob zwei Objekte identisch sind, lässt sich in JavaScript sehr leicht feststellen, da Objekte Referenztypen sind. Stimmen die beiden Referenzen überein, das heißt, verweisen sie auf dieselbe Adresse im Speicher, sind die Objekte identisch. Das ist das Standardverhalten von JavaScript, sodass ein Vergleich á la

if (user === customer) {
// ...
}

vollkommen genügt. Schwieriger sieht es bei der Gleichheit aus: Es liegt auf der Hand, dass identische Objekte automatisch auch gleich sind, doch umgekehrt gilt das nicht unbedingt. Einen Hinweis liefert der weiter oben gezeigte Code zum Implementieren von Wahrscheinlichkeiten. Er setzt explizit eine equals-Funktion um, die sogar bei einer Abweichung von 0.01 zwei Wahrscheinlichkeiten noch als gleich ansieht.

Ähnlich verhalten sich die drei genannten Module. Gibt es keine anderslautenden Angaben, geht Lodash zunächst davon aus, dass ausschließlich die Identität relevant ist. Möchten Entwickler beispielsweise mit der Funktion _.find das erste Element einer Arrays oder eines Objekts ermitteln, für das eine bestimmte Bedingung gilt, ist die standardmäßig verwendete Prädikatsfunktion die Identität. Gibt man eine eigene Funktion an, muss sie entscheiden, wann zwei Elemente als gleich gelten.

Die Datentypen von Immutable.js verfügen ebenfalls über eine equals-Funktion, mit der sich die Wertegleichheit von zwei Objekten feststellen lässt. Unter der Haube rufen alle entsprechenden Ansätze die zentrale Funktion Immutable.is auf.

Für React spielt insbesondere die Identität eine gravierende Rolle, wird sie doch verwendet, um zu entscheiden, welche Bereiche des DOM neu zu rendern sind. React führt die erwähnten Konzepte darüber hinaus auch auf der Ebene von Komponenten weiter. Funktionen, die keine Seiteneffekt aufweisen, sind in der funktionalen Programmierung als "Pure Functions" bekannt. In React spricht man bei zustandslosen Komponenten von "Pure Components". Auch die sogenannten Higher-order Components greifen die Terminologie und die Denkart der funktionalen Programmierung geschickt auf.

Das alles ließe sich noch beliebig fortsetzen, aber die Grundaussage ist stets die gleiche: Wer die grundlegenden Konzepte kennt, ist nicht so abhängig von tagesaktuellen und kurzlebigen Trends, sondern kann Neuerungen in einem ganz anderen Licht betrachten und bewerten. Konstrukte, die sich schon seit Jahrzehnten etabliert haben, werden gelegentlich auf neue Art implementiert, aber die grundsätzlichen Prinzipien bleiben gleich.

Das nimmt Spannung aus dem Alltag, in dem Entwickler ständig entscheiden müssen, in welchen Fällen das Betrachten neuer und aktualisierter Module lohnt. Lodash, Immutable.js und React sind Beispiele für Module, die Konzepte aus der funktionalen Programmierung gut umgesetzt haben, und die sich desto besser verstehen lassen, je tiefgreifender das Wissen der funktionalen Programmierung ist.

Selbstverständlich ist Letztere nicht das einzige Feld, aus dem sich heutige Module bedienen: Es ist beispielsweise sinnvoll, die symmetrische und asymmetrische Verschlüsselung verstanden zu haben. Weiß man, wie entsprechende Konzepte funktionieren, verlieren HTTPS, digitale Signaturen, Tokens & Co. schlagartig ihre Schrecken. Das gleiche gilt für viele weitere Themenbereiche.

Golo Roden
ist Gründer, CTO und Geschäftsführer der the native web GmbH, eines auf native Webtechnologien spezialisierten Unternehmens. Für die Entwicklung moderner Webanwendungen bevorzugt er JavaScript und Node.js und hat mit "Node.js & Co." das erste deutschsprachige Buch zum Thema geschrieben.
(jul)