Probleme von JavaScript

Inhaltsverzeichnis

  1. Umwandlung von Arrays in Zeichenketten
  2. Schwache Typisierung
  3. Unsinnige Regeln
  4. Inkonsistente Definitionen
  5. Globale Variablen
  6. Operatorüberladung
  7. Das this-Argument
  8. Wörterbuch-Syntax
  9. Module
  10. Die Methode map
  11. Literatur

Zusammenfassung: Dieser Text handelt von Problemen der Programmiersprache JavaScript. Dabei werden sowohl Entwurfsfehler erläutert als auch solche, welche eher konzeptueller Natur sind. Es wird jeweils darauf eingegangen, wie eine Lösung ausschauen kann oder hätte ausschauen können.

Umwandlung von Arrays in Zeichenketten

Normalerweise sollte die Umwandlung eines Arrays in eine Zeichenkette auch genau so ausschauen wie das Literal. Aber in JavaScript fehlen die eckigen Klammern:

> String([1, 2])
"1,2"

Man kann sich denken: »Gut, die kann ich ja noch hinzufügen«. Aber bei verschachtelten Arrays wird es schlimmer:

> String([[1, 2], [3, 4]])
> "1,2,3,4"

Außerdem kommt hinter einem Komma üblicherweise ein Leerzeichen. Python macht es richtig.

Schwache Typisierung

> [1, 2] + 3
"1,23"

Was passiert hier? Nun, das Array wird vor der Addition implizit in eine Zeichenkette umgewandelt. Wie wir schon gesehen haben, wird bei der Umwandlung eines Arrays in eine Zeichenkette die Klammerung ausgelassen.

Man wird es kaum glauben: JavaScript ist typsicher. Ist ein JavaScript-Interpreter korrekt implementiert, so kann kein JavaScript-Programm die Speichersicherheit gefärden. Nur weil JavaScript typsicher ist, heißt das aber nicht, dass es auch streng typisiert ist.

Unsinnige Regeln

Einige Operationen sind in JavaScript nach Regeln definiert, welche keinen praktischen Sinn ergeben.

> [] == []
false

> [1, 2] == [1, 2]
false

> [1] == [1]
false

Was soll das?

> [1] == 1
true

Ja. Logisch, oder?

> [] == 0
true

> [] == 1
false

Ja.

Inkonsistente Definitionen

Die Funktion Array(n) ist nicht zu gebrauchen. Felder haben offenbar eine tieferliegende Komplexität, gegen die der Programmierer ankämpfen muss.

> [undefined, undefined, undefined, undefined].map(x => 1)
[1, 1, 1, 1]

> Array(4).map(x => 1)
Array(4) [undefined, undefined, undefined, undefined]

> a = Array(4);
> a[0] = undefined;
> a.map(x => 1)
Array(4) [1, undefined, undefined, undefined]

Globale Variablen

In JavaScript ist es vorgesehen, dass eine undeklarierte Variable auf der linken Seite einer Zuweisenung automatisch global ist. In der Funktion

function f(x){
    y = 2*x;
    return y;
}

wird z. B. unintuitiverweise die globale Variable y geändert, was man höchstwahrscheinlich nicht wollte. Ein solches Verhalten führt in einer Vielzahl von Fällen zu seltsamen Bugs. Ein explizite deklaration von globalen Variablen wäre sinnvoll:

function f(x){
    global y = 2*x;
    return y;
}

Operatorüberladung

In JavaScript ist Operatorüberladung leider nicht möglich, obwohl es trivial zu implementieren wäre. Es kommt stattdessen folgends Verhalten zustande:

> x = {}; y = {}
> x + y
"[object Object][object Object]"

Der Interpreter überprüft ja schon zur Laufzeit, welchen Datentyp die Variablen xy besitzen. Falls der Datentyp kein einfacher ist, werden die Objekte in diesem Residualfall einfach implizit in Zeichenketten umgewandelt. Genau in diesem Residualfall wäre es aber möglich gewesen, eine zugehörige Methode add im Objekt x oder in dessen Prototypenkette aufzurufen:

x + y ≡≡ x.add(y)

Das this-Argument

In JavaScript lässt sich unter Zuhilfenahme von Closures der Kompositionsoperator formulieren:

function compose(g, f){
    return function(x){return g(f(x));};
}

Diesen Operator möchten wir nun als Methode einer Funktion haben. Man macht folgenden Ansatz:

Function.prototype.o = function(f){
    return function(x){return this(f(x));};
}

Hier ergibt sich das Problem, dass das this-Argument zur inneren Funktion gehört, und nicht zur äußeren. Stattdessen ist man gezwungen zu:

Function.prototype.o = function(f){
    var g = this;
    return function(x){return g(f(x));};
}

Es wäre sinnvoll, wenn es in JavaScript eine Möglichkeit geben würde, den Namen des this-Arguments individuell festzulegen, wie bei jeder anderen gewöhnlichen Variable auch. Z.B. so:

Function.prototype.o = function(g; f){
    return function(x){return g(f(x));};
}

Wörterbuch-Syntax

In JavaScript koinzidieren Wörterbücher mit strukturierten Objekten. Die Syntax von Wörterbuch- bzw. Objekt-Literalen besitzt das Manko, dass zunächst keine Ausdrücke als Schlüssel möglich sind. Weil nämlich die Schreibweisen

t = {"a": 1, "b": 2};
t = {a: 1, b: 2};

äquivalent sind, wäre die letztere Schreibweise zweideutig, wenn Ausdrücke als Schlüssel auf der linken Seite zugelassen wären. Man hat diesen Umstand in ES6 durch die Notation

t = {["a"]: 1, ["b"]: 2};

umgangen. Diese Notation ist umständlich lang und schaut dahingehend seltsam aus, dass die Schlüssel doch eigentlich auch Arrays sein könnten:

t = {[[1, 2]]: 1, [[3, 4]]: 2};

Hier ergibt sich ein weiteres Problem: alle Schlüssel werden implizit in Zeichenketten umgewandelt. Zumindest bei Ganzzahlen ergibt das keinen Sinn, da das direkte hashen von Ganzzahlen wesentlich effizienter ist, als Zeichenketten neu zu erzeugen. Problematisch ist weiterhin, dass die eckigen Klammern bei der Umwandlung verloren gehen, die Schlüssel

[1, [2, 3]] und [1, 2, 3]

somit gleich sind.

Meines erachtens wäre folgende Syntax sinnvoller gewesen:

t = {"x": 1, "y": 2};
t = {x = 1, y = 2};

Hiermit lassen sich auch recht angenehm benannte Argumente (wie sie in Python und R zu finden sind) simulieren.

Module

Möchte man in JavaScript ein wiederverwendbares Modul schreiben, so sollten dessen Funktionen in einem eigenen Namensraum stehen. Betrachten wir folgende Funtkionen die zunächst flach im Hauptnamensraum stehen:

function f(x){return 2*x;}
function g(x){return 3*x;}

Das Modul möchten wir nun als M bezeichnen. Die erste Idee ist, die Funktionen einfach als Slots eines Objektes M aufzufassen.

var M = {
    f: function(x){return 2*x;},
    g: function(x){return 3*x;}
};

Falls das Modul nun statische Variablen besitzt, kann auf diese über das this-Argument zugegriffen werden:

var M = {
    name: "M",
    f: function(){return this.name;}
};

Hiermit ist es auch möglich, Funktionen aus dem Modul innerhalb des Moduls aufzurufen. Hier ein Beispiel mit Rekursion:

var M = {
    f: function(x){return x == 0? 1 : 2*this.g(x-1);},
    g: function(x){return x == 0? 1 : 3*this.f(x-1);}
}

Leider kommen wir nun in einen Konflikt, wenn wir eine Funktion aus dem Modul als Argument an eine andere Funktion übergeben wollen.

var M = {
    beta: 2,
    f: function(x){return this.beta*x;},
    g: function(a){return a.map(this.f);}
};

Dieses Beispiel wird nicht wie erwartet funktionieren. Bei der Übergabe von f an map geht nämlich das this-Argument von f verloren. Somit kann nicht mehr auf beta zugegriffen werden.

Die Direkte Lösung dieses Problems ist die feste Bindung des this-Argumentes an f. Dies geschieht durch Hinzufügen von:

M.f = M.f.bind(M);

Man könnte anstelle des this-Argumentes auch direkt auf die Modulvariable zugreifen:

var M = {
    beta: 2,
    f: function(x){return M.beta*x;},
    g: function(a){return a.map(M.f);}
};

Hier muss aber beachtet werden, dass der Inhalt der Variable M nicht mehr verändert werden darf. Folgendes ist also nicht möglich:

var M2 = M;
M = undefined;

Die Lösung besteht darin, eine lokale Variable für das Modul einzuführen.

var M = (function(){
    var M = {
        beta: 2,
        f: function(x){return M.beta*x;},
        g: function(a){return a.map(M.f);}
    };
    return M;
})();

Man hätte auch alle Funktionen und Variablen direkt als lokale Variablen schreiben können.

var M = (function(){
    var beta = 2;
    var f = function(x){return beta*x;};
    var g = function(a){return a.map(f);};
    var Interface = {g: g};
    return Interface;
})();

Hier ist alles privat, was sich nicht im Interface befindet. Leider ist jetzt eine nachträgliche Manipulation des Moduls nicht mehr möglich, z. B. wenn man das Modul mit M.beta=4 konfigurieren möchte. Es ist aber möglich, konfigurierbare Eigenschaften des Moduls explizit zum Interface mit aufzunehmen.

var M = (function(){
    var config = {beta: 2};
    var f = function(x){return config.beta*x;};
    var g = function(a){return a.map(f);};
    var Interface = {g: g, config: config};
    return Interface;
})();

M.config.beta = 4;

Bisher haben wir nicht beachtet, dass es auch mehrere Instanzen eines Moduls geben könnte. Z. B. würde man von einem Modul gerne eine shallow copy machen und diese Kopie modifizieren. Alternativ kann das Prototypensystem hierfür verwendet werden.

Möchte man eine nachträgliche Manipulation verbieten, so kann das Modul als Factory formuliert werden. Eine solche nimmt eine Konfiguration und erzeugt aus dieser ein konfiguriertes Modul:

var M = (function(config){
    var beta = config.beta;
    var f = function(x){return beta*x;};
    var g = function(a){return a.map(f);};
    var Interface = {g: g};
    return Interface;
});

var M1 = M({beta: 4});

An dieser Stelle soll noch bemerkt werden, dass lokale Variablen aus dem äußeren Kontext auch in Funktionen stehen können, bevor sie definiert wurden. Da Funktionen auch in lokalen Variablen gespeichert werden, erlaubt dies Rekursion ohne Formulierung von Prototypendeklarationen wie sie in Pascal und C erforderlich sind. Die Variablen werden nämlich erste beim Verlassen der äußeren Funktion bei der Closure-Bindung durch Kopien überschrieben, vorher waren es Referenzvariablen auf die tatsächlichen Variablen. In der Programmiersprache Lua ist diese Problematik unter dem Stichwort »upvalues« bekannt.

Die Methode map

Der Methodenaufruf a.map(f) kann eine Funktion f mit mehr als einem Argument annehmen. Zusätzlich lässt sich hierdurch der Index des Array-Elements mit übergeben.

Wer auch immer das so entworfen hat, hat sich zu diesem Zeitpunkt nicht die Gedanken über die Konsequenzen gemacht, die daraus resultieren. Eine Funktion mit einem optionalen Argument bekommt dann nämlich den Index anstelle von undefined, was niemals beabsichtigt war.

Eine klare Lösung geben strenge funktionale Programmiersprachen vor. Dort gibt es eine Funktion enumerate, welche a[i] auf [i, a[i]] abbildet. Wenn das nicht effizient genug ist, kann man immer noch eine for-Schleife benutzen bzw. eine Funktion map_with_index schreiben.

Literatur

  1. Henning Thielemann: »Skriptsprachen-Hassen leichtgemacht«. (2002).
  2. Henning Thielemann: »C-Hasser in 10 Tagen«. (2002).