My focus going forward has shifted JavaScript: Kapselung und Vererbung (Teil 2)

JavaScript: Kapselung und Vererbung (Teil 3)

Published on Monday, June 23, 2014 5:00:00 AM UTC in Programming

Die Art, wie ich in Teil 2 die Vererbungshierarchie über die Prototypenkette hergestellt habe, hat eine interessante Diskussion ausgelöst, da es dazu durchaus unterschiedliche Meinungen gibt. Es lohnt sich also, einen genaueren Blick auf die verschiedenen Möglichkeiten zu werfen.

Recap und Problematik

Zur Erinnerung nochmal der Weg, den ich im vorigen Artikel gewählt habe. Zur Verdeutlichung nutze ich den instanceof-Operator, um zu zeigen, dass die Vererbung tatsächlich geklappt hat:

var myFunction = function() {
}

var derivedFunction = function() {
}
derivedFunction.prototype = new myFunction(); // set prototype to parent
derivedFunction.prototype.constructor = derivedFunction; // fix constructor

var myObject = new derivedFunction();
console.log(myObject instanceof myFunction); // output: true

Die Argumentation, warum das gezeigte Vorgehen vielleicht nicht ideal ist, führt ins Feld, dass man hier verschiedene Semantiken mischt: als Prototyp, der ja Vorlage für erzeugte Objekte sein soll, wird hier plötzlich eine "Instanz" verwendet (new myFunction()), was irgendwie zu einer semantischen Aufweichung führt. Mir persönlich steht diese Begründung auf zu wackeligen Füßen: da JavaScript ja weder Klassen noch Instanzen oder gar richtige Vererbung kennt, und man hier versucht, der Sprache diese Konstrukte durch technische Kniffe irgendwie aufzuzwingen, scheint mir die Diskussion über derartige Details arg substanzlos.

Allerdings entwickelt sich dieses "Gschmäckle" tatsächlich zum sehr ernsthaften Problem, sobald ein Konstruktor zur Parametrierung Argumente erwartet. Beispiel:

var myFunction = function(name) {
    this.name = name;
}

var myObject = new myFunction("Karl-Heinze");

console.log(myObject.name); // output: "Karl-Heinze"

Versucht man nun auf diesselbe Art und Weise wie oben eine Prototypen-Kette herzustellen, stößt man auf ein offensichtliches Problem:

var derivedFunction = function() {
}
derivedFunction.prototype = new myFunction("???"); // what to pass here?

Die Vermischung von Prototypen- und "Instanzen"-Semantik wird nun zum Hindernis, da man zum Zeitpunkt der Erstellung des Prototypen keine Entscheidung über die Parametrierung der Instanz treffen kann und möchte. Schlimmer noch: Objekte, die mit einem solchen Konstruktor erstellt werden, sind quasi in einem undefinierten Zustand, bis die Initialisierung z.B. in diesem Fall der "name"-Eigenschaft nach der Objekterzeugung manuell nachgeholt wird – das ist fehleranfällig und sicherlich nicht ideal.

Object.create FTW?

Unstrittig ist, dass man die beiden Ideen, nämlich eine Ableitungshierarchie zu bauen und dann unterschiedliche Objekte zu erzeugen, entweder von einander trennen oder zu einer atomaren Einheit zusammenführen muss. Auf der Suche nach Lösungen stolpert man zwangsläufig über die Funktion "Object.create". Diese erlaubt es unter anderem, ein neues Objekt unter Angabe eines Prototypen unter Umgehung der zugehörigen Konstruktor-Funktion zu erstellen. Zur Verdeutlichung:

var myFunction = function(name) {
    this.name = name;
}
myFunction.prototype.doSomething = function() {
    console.log("huhu");
}

var myObject = Object.create(myFunction.prototype);
myObject.doSomething(); // output: "huhu"
console.log(myObject.name); // output: "undefined"

Es wurde also in der Tat ein Objekt erzeugt, das die Prototyp-Eigenschaften von "myFunction" erbt, nicht aber jene, die über den Konstruktor gesetzt würden. In Kombination mit gezielten hierarchischen Konstruktor-Aufrufen lässt sich mit dieser Variante dann das oben beschriebene Problem lösen:

var myFunction = function(name) {
    this.name = name;
}

var derivedFunction = function(name) {
    // call the "base" constructor
    myFunction.call(this, name);
}

derivedFunction.prototype = Object.create(myFunction.prototype);

var myObject = new derivedFunction("Karl-Heinze");
console.log(myObject.name); // output: "Karl-Heinze"

Tatsächlich muss man jetzt zur Definitionszeit der Prototypenkette keine "Instanz"-Entscheidungen mehr treffen, sondern verschiebt diese auf den Zeitpunkt der Objekterzeugung – ganz wie es sein sollte. Trotzdem hat diese Vorgehensweise Nachteile: anders als bei den meisten klassenbasierten Sprachen, bei denen der Basis-Konstruktor von abgeleiteten Klassen automatisch aufgerufen wird, gibt es hier keinerlei Beziehung zwischen den beiden involvierten Funktionen, und damit natürlich auch keinerlei solchen Automatismus. Es liegt also alleine in der Verantwortung des Entwicklers von "derivedFunction", dass er die Ableitungshierarchie manuell und korrekt aufrecht erhält. Außerdem verbackt man die konkrete Hierarchie direkt in die Implementierung, da es technisch keine inhärente Methode gibt, wie man relative Konstruktoraufrufe machen könnte (es gibt eben kein "base").

Umgehung des Schlüsselworts "new"

Eine fortgeschrittene Technik, die dieses Problem minimieren möchte, nutzt eine andere Variante der Methode "Object.create". Diese erlaubt es nämlich, über einen zweiten Parameter die Eigenschaften des zu erzeugenden Objekts zu bestimmen. Damit kann man den zweistufigen Prozess, zunächst die Prototypenkette zu erstellen und danach per "new" die zugehörigen Objekte zu erstellen, wieder zu einem einzelnen Schritt zusammenfassen, ohne aber auf die eingangs demonstrierten Probleme zu stoßen. Im Prinzip nutzt man "Object.create" also als eine Art Factory-Methode, die in einer atomaren Operation sowohl die Prototypenkette als auch die "Instanz"-Eigenschaften bestimmt. Etwa so:

var myFunction = function(name) {
    this.name = name; // hint: is never used!
}
myFunction.prototype.doSomething = function() {
    console.log("huhu");
}

var myObject = Object.create(myFunction.prototype, 
                             { 
                                 "name": { value: "Karl-Heinze" }
                             });
myObject.doSomething(); // output: "huhu"
console.log(myObject.name); // output: "Karl-Heinze"

Ganz ohne die Verwendung von "new" hat man nun also ein Objekt der gewünschten Ausprägung erzeugt – es hat den Prototyp von "myFunction" und eine Eigenschaft "name" erhalten. Problem gelöst(?).

Wer genau hinsieht merkt allerdings, dass die Deklaration und Initialisierung im Konstruktor nun eigentlich völlig überflüssig ist. Er wird nie ausgeführt, die Eigenschaft "name" von "myObject" wurde demnach nicht von "myFunction" erzeugt und steht auch sonst in keinerlei Beziehung dazu. Man kann man sich diese Initialisierung, und damit den gesamten Konstruktor-Inhalt also eigentlich komplett sparen. Und tatsächlich ist das eine Philosophie, die vielerorts propagiert wird: der Übergang weg von Konstruktorlogik hin zur Erzeugung von Objekten per "Object.create".

Kompletter Verzicht auf explizite Prototypen?

In einer sehr lesenswerten, dreiteiligen Serie (besonders empfehlenswert ist Teil 3) tritt Kyle Simpson dafür ein, sich von der expliziten Arbeit mit Prototypen zu verabschieden und auf das zu konzentrieren, worum es in JavaScript immer geht: Objekte. Diese Alternative wird auch an anderen, teils prominenten Stellen propagiert. Es funktioniert etwa so:

var myObject = {
    name: "",
    init: function(name) { this.name = name; },
    doSomething: function() { console.log("my name is: " + this.name); }
};

var anotherObject = Object.create(myObject);
anotherObject.init("Karl-Heinze");
anotherObject.doSomething(); // output: "Karl-Heinze"

Man erstellt also nun ein ganzes Objekt als Vorlage und erzeugt durch "Object.create" quasi nur noch Klone. Obwohl diese Variante sehr einfach und geordnet wirkt, birgt sie allerdings ein paar Gefahren in sich: man sollte immer daran denken, dass "Object.create" als erstes Argument eigentlich einen Prototypen erwartet. Das bedeutet, dass "anotherObject" im konkreten Beispiel zunächst mitnichten eigene Eigenschaft "name" besitzt, sondern dessen Prototyp. Erst bei der Verwendung der Methode "init", wenn die Eigenschaft "name" beschrieben wird, wird sie in eine lokale Eigenschaft kopiert. Im gezeigten Beispiel funktioniert damit alles wie gewünscht, aber bei komplexeren Strukturen kommt es sehr schnell zu subtilen aber gravierenden Problemen:

var myObject = {
    names: [],
    addName: function(name) { this.names.push(name); },
    doSomething: function() { console.log("my names are: " + this.names); }
};

var anObject = Object.create(myObject);
anObject.addName("Karl-Heinze");
anObject.doSomething(); // output: "Karl-Heinze"

var anotherObject = Object.create(myObject);
anotherObject.addName("Pelzhelm");
anotherObject.doSomething(); // output: "Karl-Heinze,Pelzhelm"!

Wie man sieht, funktioniert der Mechanismus zum Kopieren von Eigenschaften aus dem Prototypen ins Objekt nur bei Primitiven. Schon die Verwendung von Arrays führt dazu, dass die Eigenschaft im Prototypen verbleibt und damit alle "Instanzen" von "myObject" dieselbe Eigenschaft teilen – was meist nicht dem gewünschten Verhalten entspricht. Zwar gibt es auch dafür natürlich Lösungen, etwa über die Methode "hasOwnProperty" herauszufinden, ob die Eigenschaft lokal oder nur über die Prototypenkette erreichbar ist, und ggfs. manuelle Korrekturen vorzunehmen, aber das bürdet wieder dem einzelnen Entwickler viel Verantwortung auf und erhöht die Fehleranfälligkeit.

Die erste gezeigte Variante, bei der die Eigenschaften über das zweite Argument von "Object.create" festgelegt werden, hat diese Probleme – obwohl ungleich schlechter zu lesen – nicht.

Hinweis zur Kompatibilität von Object.create

Während die Variante mit explizitem "new" auch in älteren Browsers funktioniert, gibt es mit "Object.create" teils Probleme, insbesondere bei IE < Version 9. Man kann sich durch folgenden Polyfill behelfen:

if (typeof Object.create !== "function") {
    (function () {
        var F = function () {};
        Object.create = function (o) {            
            F.prototype = o;
            return new F();
        };
    })();
}

Für die Nutzung des zweiten Arguments gibt es verschiedene Vorschäge im Netz, die aber in diversen Details nicht das Originalverhalten nachbilden können und daher mit Vorsicht zu genießen sind.

Fazit

Wie also kann eine Empfehlung aussehen? Ehrlich gesagt weiß ich das (noch?) nicht. Meine persönlichen "Lessons Learned" bis hierhin sind:

  • Die Technik und das Konzept hinter dem Prototyp-Mechanismus sind verblüffend einfach.
  • Die diversen Probleme und Grenzfälle, die überall diskutiert werden, sind eine direkte Folge der Art, wie Prototypen genutzt werden; häufig wird der Mechanismus überhaupt nicht richtig verstanden und/oder misinterpretiert.
  • Im Nachhinein finde ich es unglücklich, für die Mini-Artikelserie überhaupt Begriffe wie "Vererbung" genutzt zu haben, auch wenn ich ausgiebig und oft doppelte Anführungszeichen verwendet habe :). Das Problem ist, dass all diese Begriffe aus der klassenorientierten Welt bei Entwicklern eine sehr genaue Erwartungshaltung auslösen, die aber so niemals in JavaScript erfüllt werden kann.
  • Tendenziell würde ich also auch dazu neigen, künftig keine klassenspezifische Begrifflichkeiten mehr zu JavaScript hinüberzutragen, und mich stattdessen auf die nativen Konstrukte und Begriffe zu konzentrieren, also etwa "Delegation" statt "Vererbung", und die komplette Vermeidung von (falschen) Begriffen wie "Instanz" – leider macht das JavaScript selbst nicht ganz einfach, weil es mit "new" und Operatoren wie "instanceof" die Verwirrung auch selbst zu schüren scheint. Trotzdem muss natürlich ein Weg gefunden werden, wie Delegation und der Prototyp-Mechanismus gerade in einem Team sauber und einheitlich umgesetzt werden kann; aber es ist schwierig, pauschale Empfehlungen zu geben. Die ausschließliche Arbeit mit Objekten ohne explizites Handling von Prototypen ist verlockend einfach, bringt aber subtile Probleme mit sich. Alles per "Object.create" zu erledigen, verlangt komplexe Polyfills für ältere Browser, die aber dennoch kein hundertprozentig identisches Verhalten zu nativen Implementierungen erreichen (können). Außerdem beginnt man dann mit der Entwicklung von Factory-Funktionen für die Objekterzeugung, die sich wieder der ursprünglichen Konstruktorlogik annähern. Da scheint die Variante mit "Object.create" (ggfs. Polyfill) und Konstruktorlogik momentan die robusteste Lösung, insbesondere wenn es um Browserkompatibilität geht.

Insbesondere denke ich aber, dass es in dieser Frage keinen dauerhaften und gemeinsamen Konsens in der Community geben wird :).

Tags: JavaScript · Kapselung · Vererbung