JavaScript: Kapselung und Vererbung (Teil 3) JavaScript: Kapselung und Vererbung (Teil 1)

JavaScript: Kapselung und Vererbung (Teil 2)

Published on Friday, June 20, 2014 3:00:00 AM UTC in Programming

Mit den Grundlagen aus dem ersten Teil des Artikels können wir nun einen Blick auf die Möglichkeiten der Vererbung mittels des Prototypen-Mechanismus in JavaScript werfen. Lange Zeit habe ich diesen "irgendwie" genutzt, ohne aber die Funktionsweise vollständig zu verstehen. Es hat sich gelohnt, das nachzuholen.

Update 2014-06-20: ich habe einige Best Practices zur Etablierung der Vererbung eingearbeitet.

Grundsätzliches zu Prototypen

In JavaScript sind Prototypen spezielle Objekte, die sowohl Eigenschaften als auch Funktionen für die Wiederverwendung durch andere Objekten zur Verfügung stellen können. Prototypen sind zunächst einmal in Funktionen angesiedelt. Bei der Deklaration einer Funktion wird immer automatisch ein neuer Prototyp erzeugt und die "prototype"-Eigenschaft der Funktion damit initialisiert. Zunächst erhält dieser neue Prototyp lediglich eine einzige Eigenschaft "constructor", die auf die Funktion selbst zeigt.

Beim Erstellen von Objekten mit der Funktion als Konstruktor erhält das Objekt einen Verweis auf den Prototypen der Funktion. Der de facto-Standard dafür ist eine Eigenschaft mit dem Namen "proto". Das fertig konstruierte Objekt kennt also den Prototypen der Funktion, mit der es erzeugt wurde, und über dessen Eigenschaft "constructor" auch die Funktion bzw. den Konstruktor selbst. Zur Verdeutlichung:

var myFunction = function() {
}

var myObject = new myFunction();

console.log(myObject.__proto__ === myFunction.prototype); // true
console.log(myObject.__proto__.constructor === myFunction); // true

Prototypenkette

Ein interessantes Detail an Prototypen ist, dass sie selbst natürlich auch Objekte sind und damit wie jedes andere Objekt ebenfalls eine Eigenschaft "proto" besitzen, die ggfs. auf ihren eigenen Prototypen zeigt. Über diesen Mechanismus werden Prototypenketten gebaut, die von einem konkreten Objekt bis hin zum Prototypen des allgemeinsten Typs (dem Ende der Kette) führen.

Auf oberster Ebene ist dabei der Typ "object" bzw. die Funktion "Object" zu sehen, bei der die "proto"-Eigenschaft des Prototypes null ist und damit die Kette auf jeden Fall unterbricht. Für unser obiges Beispiel gilt also (letzte Zeile):

var myFunction = function() {
}

var myObject = new myFunction();

console.log(myObject.__proto__ === myFunction.prototype); // true
console.log(myObject.__proto__.constructor === myFunction); // true
console.log(myObject.__proto__.__proto__ == Object.prototype); // true

Es handelt sich also um eine zweistufige Prototypenkette, die zunächst auf den Prototypen der Funktion "myFunction" zeigt, und von dort aus auf den Prototypen der Funktion "Object".

Suchstrategie für Member

Bisher wurde noch nicht klar, wozu dieser Aufwand getrieben wird. Der Prototypen-Mechanismus kommt immer dann zum Einsatz, wenn auf ein Member eines Objekt zugegriffen, also eine Eigenschaft oder eine Funktion genutzt werden soll. Die Laufzeitumgebung sucht dann zunächst im Objekt selbst nach dem gewünschten Member. Wird es dort nicht gefunden, wird über die "proto"-Referenz im Prototypen des Objekts gesucht, ob ein passendes Member dort gefunden werden kann. Ist es auch dort nicht vorhanden, wird die nächste Stufe durchsucht (also "proto.proto") usw., bis entweder das gesuchte Member gefunden wurde oder es keinen höhergelegenen Prototypen mehr gibt. Beispiel:

var myFunction = function() {
}

var myObject = new myFunction();
console.log(myObject.toString());

Dieser Code wird anstandslos ausgeführt, obwohl auf dem Objekt "myObject" gar keine Methode "toString" definiert wurde. Der Grund dafür ist, dass die Laufzeitumgebung, nachdem "toString" im Objekt selbst nicht gefunden wurde, dessen Prototypen durchsucht. Der Prototyp ("myFunction.prototype") enthält aber auch keine solche Methode, weshalb das nächste Glied der Prototypenkette inspiziert wird. Tatsächlich enthält der Prototyp von "Object" die Methode "toString", und diese wird nun mit dem "this"-Kontext "myObject" ausgeführt.

Prototypen erweitern

Nun wird es interessant. Man kann Prototypen eigene Funktionalität in Form von Eigenschaften und Methoden hinzufügen. Diese stehen dann über den beschriebenen Suchmechanismus allen Objekten zur Verfügung, die mit dem zum Prototypen gehörigen Konstruktor erzeugt wurden. Auch hierzu wieder ein Beispiel:

var myFunction = function() {
}

myFunction.prototype.doSomething = function() {
    console.log("I did something");
}

var myObject = new myFunction();
myObject.doSomething(); // output: "I did something"

Da der Kontext korrekt gesetzt wird, steht in der Prototyp-Methode auch das konkrete Objekt per "this" zur Verfügung und kann damit natürlich lesend wie schreibend genutzt werden.

Die Sache mit der Vererbung

In den bisherigen Beispielen wurde automatisch eine Prototypen-Kette von unserem eigenen Typ zu "Object" erstellt. Was aber, wenn man selbst Hierarchien bauen möchte und mehrere selbst erstellte Typen derart in Beziehung setzen möchte? Die Antwort ist relativ einfach: man ändert für den Konstruktor die "prototype"-Eigenschaft, dass sie auf ein Objekt des gewünschten Eltern-Konstruktors zeigt. Beispiel:

var myFunction = function() {
}
myFunction.prototype.doSomething = function() {    
    console.log("I did something");
}

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

var myObject = new derivedFunction();
myObject.doSomething(); // output: "I did something"

Hier wird also ein Objekt mittels des Konstruktors "derivedFunction" erstellt. Da dessen Prototyp auf ein Objekt zeigt, das mit dem Konstruktor "myFunction" erstellt wurde, funktioniert die Prototypenkette korrekt, und zur Laufzeit wird die Methode "doSomething" auf der Ebene des Elterntyps "myFunction" im Prototyp gefunden und ausgeführt.

Ein bemerkenswertes Detail ist die Zeile mit dem Kommentar "fix constructor". Beim Ändern des Prototypen von "derivedFunction" auf eine Instanz von "myFunction" wurde auch die Eigenschaft "constructor" verändert. Sie würde fortan durch die schon mehrfach beschriebene Suchstrategie im Prototype von "myFunction" gefunden und auf eben jene zeigen, was aber falsch wäre. Objekte, die von "derivedFunction" erzeugt wurden, sollten ja auch auf diese Funktion als "constructor" verweisen. Daher ist diese Korrektor nach der Änderung des Prototyps manuell nötig.

Hinweis: vereinzelt sieht man auch, dass der Prototyp des "abgeleiteten" Konstruktors schlicht auf den Prototyp des Eltern-Konstruktors gesetzt wird. Damit funktioniert aber die beschriebene Suchstrategie nur teilweise, da hierdurch die direkten Member, die im Eltern-Konstruktor selbst auf erzeugten Objekten definiert werden, nicht gefunden werden können. Außerdem zeigen die beiden Referenzen auf dasselbe Prototypen-Objekt, was beispielsweise bedeutet, dass Änderungen am Prototyp der Ableitung auch direkt den Eltern-Konstruktor betreffen. Beispiel:

var myFunction = function() {
    this.anotherFunction = function() {
        console.log("hit me!");
    }
}

myFunction.prototype.doSomething = function() {    
    console.log("I did something");
}

var derivedFunction = function() {
}
derivedFunction.prototype = myFunction.prototype; // BAD
derivedFunction.prototype.constructor = derivedFunction;

var myObject = new derivedFunction();
myObject.doSomething();
myObject.anotherFunction(); // error!

Diese Art des "Durchreichens" von Prototypen sollte man also vermeiden.

Zugriffsasymmetrie

Im Gegensatz zu den Beispielen aus dem ersten Artikel, bei denen alle Eigenschaften und Funktionen durch Duplizieren in allen erzeugten Objekten landeten, kann ein Prototyp als Container für statische Eigenschaften und Funktionen betrachtet werden. Die im Prototyp definierten Member werden nicht in die einzelnen Objekte kopiert, sondern durch die Suchstrategie im Prototyp gefunden und dort aufgerufen. Damit ist diese Implementierung im direkten Vergleich deutlich speichereffizienter, benötigt aber etwas mehr Leistung durch den Suchaufwand.

Ein unmittelbares Problem, das sich durch diese Konstruktion aber ergeben würde, ist, dass Prototyp-Eigenschaftswerte von allen Objekten, die denselben Prototyp verwenden, geteilt werden – das ist sicherlich in den meisten Fällen ein unerwünschtes Verhalten. Zur Demonstration, dass das dem reales Verhalten entspricht, kann man sich dieses Beispiel konstruieren:

var myFunction = function() {
}
myFunction.prototype.someValue = 42;

var firstObject = new myFunction();
var secondObject = new myFunction();

console.log(firstObject.someValue); // 42
console.log(secondObject.someValue); // 42

myFunction.prototype.someValue = 23;

console.log(firstObject.someValue); // 23
console.log(secondObject.someValue); // 23

Glücklicherweise existiert ein in die Sprache integrierter Mechanismus, der diese Problematik verhindert. Er funktioniert folgendermaßen:

  • Lesende Zugriffe auf Prototyp-Eigenschaften holen den Wert aus dem Prototyp.
  • Der erste schreibende Zugriff auf eine Prototyp-Eigenschaft kopiert die Eigenschaft aus dem Prototyp in das Objekt und verändert den Wert nur dort.
  • Folgende Lesezugriffe holen den Eigenschaftswert durch die Suchstrategie automatisch direkt aus dem Objekt.

Das kann man durch ein leicht geändertes obiges Beispiel demonstrieren:

var myFunction = function() {
}
myFunction.prototype.someValue = 42;

var firstObject = new myFunction();
var secondObject = new myFunction();

console.log(firstObject.someValue); // 42
console.log(secondObject.someValue); // 42

firstObject.someValue = 23;

console.log(firstObject.someValue); // 23
console.log(secondObject.someValue); // 42
console.log(myFunction.prototype.someValue); // 42

Belegen kann man es auch über die vom "Object"-Prototyp bereitgestellte Methode "hasOwnProperty", die prüft, ob eine Eigenschaft direkt auf dem Objekt definiert ist oder aber über die Suchstrategie von einem Prototypen kommt. Beispiel:

var myFunction = function() {
}
myFunction.prototype.someValue = 42;

var firstObject = new myFunction();
console.log(firstObject.hasOwnProperty("someValue")); // false

firstObject.someValue = 23;
console.log(firstObject.hasOwnProperty("someValue")); // true

Wie man sieht, führte das Setzen der Eigenschaft tatsächlich zu einer jetzt lokalen Definition derselben im Objekt selbst.

Einschränkungen

Eine Limitierung von Funktionen, die man auf dem Prototypen definiert, ist, dass diese nicht auf private Member des Konstruktors zugreifen können. Also:

var myFunction = function() {
    var someValue = 42;

    this.doSomething = function() {
        console.log(someValue); // works
    }
}

myFunction.prototype.doSomethingProto = function() {
    console.log(someValue); // error: "someValue is undefined"
};

var myObject = new myFunction();
myObject.doSomething();
myObject.doSomethingProto();

Der Zugriff auf Member des Kontext (also des eigentlichen Objekts) ist aber weiterhin und ohne Einschränkungen möglich.

Fazit

Ich hoffe, dass dem ein oder anderen beim Lesen auch ein Licht aufging, oder dass sich zumindest ein kleiner Aha-Effekt einstellte. Mir hat es jedenfalls sehr geholfen, mich (endlich) intensiver mit der Thematik zu beschäftigen. Weiterführende Links und mehr Material zu dem Thema habe ich schon im ersten Artikel zusammengetragen.

Tags: JavaScript · Prototype · Vererbung