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.
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 [1], Immutable.js [2] und React [3].
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 [4]. 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.
Gebrochene Versprechen
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 [5]. 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.
Parallelisieren ĂŒber Abstraktion
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? [6]" 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.
Seiteneffekte vermeiden
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)
);
};
Keine neuen Konzepte
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 [7] und nconc [8]. 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.
Wann sind zwei Objekte gleich?
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 [9] 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.
Fazit
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 [10], 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 [11])
URL dieses Artikels:
https://www.heise.de/-3714801
Links in diesem Artikel:
[1] https://lodash.com/
[2] https://facebook.github.io/immutable-js/
[3] https://facebook.github.io/react/
[4] https://www.joelonsoftware.com/2002/01/06/fire-and-motion/
[5] https://medium.com/@cscalfani/goodbye-object-oriented-programming-a59cda4c0e53
[6] https://www.joelonsoftware.com/2006/08/01/can-your-programming-language-do-this/
[7] http://clhs.lisp.se/Body/f_append.htm
[8] http://clhs.lisp.se/Body/f_nconc.htm
[9] https://facebook.github.io/immutable-js/docs/#/is
[10] https://www.thenativeweb.io/
[11] mailto:jul@heise.de
Copyright © 2017 Heise Medien