molily Navigation

Pseudoklassische Vererbung in JavaScript

Pseudoklassen in JavaScript

Objektorientierte Programmierung in JavaScript funktioniert anders als in anderen verbreiteten Programmiersprachen. JavaScript kennt keine Klassen, sondern veränderbare Objekte und prototypische Delegation.

Ferner kennt JavaScript Konstruktoren. Das sind Funktionen, die mittels new aufgerufen werden und Instanzen erzeugen. Sie erben prototypisch von einem Objekt, welches in der prototype-Eigenschaft des Konstruktors gespeichert ist. Dies wird von manchen als Inkonsistenz angesehen, da es abseits der funktionalen und prototypischen Ausrichtung von JavaScript steht. Die Nutzung von Funktionen als Konstruktoren mittels new ist eine Anleihe von klassenbasierten Sprachen – JavaScript ist jedoch keine solche. Douglas Crockford schildert diese Hybridtechnik in seinem Buch JavaScript. The Good Parts:

JavaScript is conflicted about its prototypal nature. Its prototype mechanism is obscured by some complicated syntactic business that looks vaguely classical. Instead of having objects inherit directly from other objects, an unnecessary level of indirection is inserted such that objects are produced by constructor functions.

Diese Kombination aus Konstruktor, Prototyp und new-Instanzen wird Pseudoklasse genannt, da sie den Anschein einer Klassendeklaration erweckt, von der Instanzen erzeugen werden. Ein Beispiel:

function Katze () {}
Katze.prototype.miau = function () {
   alert('Miau!');
};
var maunzi = new Katze();
maunzi.miau();

Douglas Crockford bezeichnet diese Fähigkeit als hilfreich, aber irreführend für JavaScript-EinsteigerInnen:

The pseudoclassical form can provide comfort to programmers who are unfamiliar with JavaScript, but it also hides the true nature of the language.

Worauf seine Kritik letztlich abzielt, ist nicht das bloße Deklarieren von Pseudoklassen und das Erzeugen von Instanzen. Ihm geht es darum, dass EntwicklerInnen klassenbasierter Sprachen daran gewöhnt sind, komplexe Vererbungshierarchien zwischen Klassen anzulegen:

The classically inspired notation can induce programmers to compose hierarchies that are unnecessarily deep and complicated.

Da JavaScript keine statisch getypte Sprache ist, sondern sämtliche Objekte veränderbar sind, ergeben starre Vererbungshierarchien in JavaScript keinen Sinn.

Eine direkte Vererbung, wie sie Crockford als Alternative vorschwebt, kann etwa mit Object.create() aus ECMAScript 5 erreicht werden.

Vererbung zwischen Pseudoklassen

Ich habe die Kritik an pseudoklassischer Vererbung bewusst vorangestellt, denn im Folgenden soll es darum gehen, wie sie sich praktisch umgesetzt lässt. Bibliotheken wie Prototype, Mootools, Dojo und Yahoo YUI bieten leistungsfähige Helfer zum Erzeugen und Ableiten von »Klassen« an. Diese zu diskutieren und ihre Funktionsweise zu erklären, wäre ein größeres Unterfangen. (Ich habe dies bisher mit Yahoo YUI getan.) Darum soll es hier nicht gehen, sondern um eine mögliche einfachere Umsetzung.

Unter der Haube funktioniert sämtliche Vererbung in JavaScript prototypisch. Das heißt, ein Objekt delegiert Anfragen an nicht existierende Eigenschaften an ein anderes Objekt, das sozusagen hinter ihm steht und ihm aushilft – siehe Prototypen verstehen.

In der Ausgangssituation liegen zwei Pseudoklassen vor, von denen eine von der anderen erben soll:

function Auto () {}
Auto.prototype.fahr = function () {
   alert('Brumm, brumm');
};

function Elektroauto () {}
Elektroauto.prototype.lade = function () {
   alert('Wird geladen');
};

Wenn ein Elektroauto erzeugt wird, soll es wie jedes Auto auch fahren können.

Einfache, verbreitete Umsetzung

Eine verbreitete Umsetzung erzeugt eine Instanz von Auto und verwendet diese als Elektroauto-Prototyp:

Elektroauto.prototype = new Auto();

Dies ist von der Idee schon ganz gut: Ein neues Objekt, welches Auto.prototype als Prototyp besitzt, wird als Elektroauto-Prototyp verwendet. Allerdings ergibt es in der Regel keinen Sinn, dazu den Auto-Konstruktor aufzurufen und eine Auto-Instanz zu erzeugen. Es ist ja kein spezifisches Auto, das als Prototyp dienen soll. Besonders wenn der Auto-Konstruktor noch Parameter entgegennimmt, um die Instanz zu initialisieren, passt dieses Konzept nicht.

Stattdessen möchte ich eine Umsetzung vorstellen, bei der der Auto-Konstruktor nicht zum Zwecke der Vererbung zwischen den Pseudoklassen aufgerufen wird.

Robustere Umsetzung

La Jamais Content, 1899

Quelle: Wikipedia Commons, gemeinfrei

Dieser Weg umfasst folgende Schritte:

  1. Erzeuge ein neues, leeres Objekt, welches Auto.prototype als Prototyp hat. Das heißt, der Prototyp-Verweis – die interne [[Prototype]]-Eigenschaft – zeigt auf Auto.prototype.
  2. Nutze dieses Objekt als Prototyp für alle Elektroautos. Das heißt, speichere es in Elektroauto.prototype.
  3. Fülle selbiges Objekt mit den besonderen Eigenschaften und Methoden von Elektroautos.
  4. Rufe im Elektroauto-Konstruktor den Konstruktor der Super-Klasse auf; das ist Auto.

Schauen wir uns nun die konkrete Implementierung der einzelnen Schritte an.

Schritt 1: Sub-Prototyp vom Super-Prototyp ableiten

Für das Erzeugen eines neuen Objekts, welches ein gegebenes Objekt als Prototyp besitzt, gibt es in ECMAScript 5 die besagte Methode Object.create(). Bis alle Browser diese unterstützen, kann man sich mit folgender Helferfunktion Object_create abhelfen:

Object_create = Object.create || function (o) {
   var F = function() {};
   F.prototype = o;
   return new F();
};

Es wird davon abgesehen, Object.create in den Browsern anzulegen, die die Methode noch nicht kennen. Denn die Originalmethode aus ECMAScript 5 kann noch viel mehr (Stichwort Property Descriptors). Es soll nicht der Eindruck erweckt werden, dass diese kleine Helferfunktion ebenbürtig ist und die Browserlücke vollständig schließen kann.

Die Anwendung sieht in unserem Fall wie folgt aus: Object_create(Auto.prototype) erzeugt ein Objekt, das Anfragen an unbekannte Eigenschaften an Auto.prototype delegiert.

Schritt 2: Sub-Prototyp zuweisen und constructor-Verweis korrigieren

Das soeben erzeugte Objekt wird nun in der prototype-Eigenschaft des Elektroauto-Konstruktors gespeichert:

Elektroauto.prototype = Object_create(Auto.prototype);

Damit wird das Objekt zum Prototyp aller künftig erzeugten Elektroauto-Instanzen.

In der prototype-Eigenschaft einer jeden Funktion ist anfangs ein Objekt gespeichert, welches eine Eigenschaft constructor besitzt. Diese verweist zurück auf die Funktion, in Beispiel Elektroauto. Wenn das bestehende prototype-Objekt wie in obigem Code überschrieben wird, geht diese Eigenschaft verloren. Damit die constructor-Eigenschaft bei Elektroauto-Instanzen auf Elektroauto und nicht auf Auto zeigt, wird der constructor-Verweis korrigieren:

Elektroauto.prototype.constructor = Elektroauto;

Schritt 3: Eigenschaften und Methoden hinzufügen

An den soeben erzeugen Sub-Prototyp können wir nun Eigenschaften und Methoden von Elektroautos anhängen. Beispielsweise eine lade-Methode:

Elektroauto.prototype.lade = function () {
   alert('Wird geladen');
};

Schritt 4: Super-Konstruktor aufrufen

Im Elektroauto-Konstruktor ist es nützlich, den Super-Konstruktor Auto aufzurufen, damit dieser allgemeine Initialisierung übernehmen kann. Technisch gesehen am effizientesten ist das direkte Aufrufen der Konstruktorfunktion im Kontext der Instanz. Die funktionale Umsetzung ist für Neulinge aber erst einmal schwer zu verstehen: Man verwendet die Methoden call oder apply, welche allen Funktionsobjekten eigen sind.

function Elektroauto () {
   // Allgemeine Initialisierung (Auto)
   Auto.call(this);
   // Spezifische Initialisierung (Elektroauto)
   // ...
}

call ruft den Konstruktor als normale Funktion auf. Es wird jedoch die Instanz als Kontext-Objekt übergeben (der erste Parameter von call). Das Schlüsselwort this zeigt im Auto-Konstruktor damit auf die Elektroauto-Instanz. Mithilfe von call lassen sich auch Parameter weitergeben, sie werden als weitere call-Parameter notiert.

Das folgende Beispiel ruft den Super-Konstruktor auf und gibt den Parameter name weiter:

// Konstruktoren
function Auto (name) {
   this.name = name;
}

function Elektroauto (name) {
   Auto.call(this, name);
}

// Pseudoklassen-Vererbung
Elektroauto.prototype = Object_create(Auto.prototype);
Elektroauto.prototype.constructor = Elektroauto;

// Erzeuge Beispiel-Instanzen
var auto = new Auto("2CV");
var elektroauto = new Elektroauto("La Jamais Contente");

Allgemeine Helferfunktion

Die obigen Schritte zeigen die konkrete Anwendung. Um das Verfahren zu verallgemeinern, lassen sie sich in eine allgemeine Helferfunktion auslagern. Sie geht davon aus, dass Object_create bereits definiert wurde:

function inheritPseudoClass (Super, Sub) {
   Sub.prototype = Object_create(Super.prototype);
   Sub.prototype.constructor = Sub;
}

Der vierte Schritt, der Aufruf des Super-Konstruktors, lässt sich leider nicht einfach verallgemeinern und verkürzen. Der direkte Aufruf von Auto.call(this, …) im Sub-Konstruktor ist im Grunde die beste Lösung. Man könnte am Sub-Konstruktor einen Verweis auf den Super-Konstruktor anlegen, z.B. superConstructor. Damit muss man ihn im Sub-Konstruktor nicht beim Namen nennen, stattdessen müsste man beispielsweise Auto.superConstructor.call(this, …) notieren. Darin sehe ich keinen Vorteil. Einen superConstructor-Verweis an der Instanz anzulegen, wäre ebenfalls möglich, wenn auch kritikwürdig. Damit wären wir bereits bei der Komplexität anspruchsvoller Lösungen. Daher enthält obige Helferfunktion keine allgemeine Lösung.

Die Funktion erwartet zwei Pseudoklassen (Konstruktoren) als Parameter; als ersten die Oberklasse, als zweiten die abgeleitete, erbende Klasse. Damit lässt sich das Auto/Elektroauto-Beispiel wie folgt umsetzen:

// Konstruktoren
function Auto (name) {
   this.name = name;
   alert('Auto wird gebaut');
}

// Lege Methoden an
Auto.prototype.fahr = function () {
   alert(this.name + ' macht brumm, brumm');
};

function Elektroauto (name) {
   Auto.call(this, name);
   alert('Batterie und Elektromotor werden eingebaut');
}

// Pseudoklassen-Vererbung
inheritPseudoClass(Auto, Elektroauto);

// Lege Methoden an
Elektroauto.prototype.lade = function () {
   alert(this.name + ' wird geladen');
};

Um die Vererbung zu testen, werden zwei Instanzen erzeugt und anschließend sowohl vererbte als auch eigene Methoden aufgerufen:

// Erzeuge Beispiel-Instanzen
var auto = new Auto('2CV'); // Auto 2CV wird gebaut
var elektroauto = new Elektroauto('La Jamais Content');
                  // Auto La Jamais Content wird gebaut,
                  // La Jamais Content: Batterie und Elektromotor werden eingebaut

auto.fahr(); // 2CV macht brumm, brumm
elektroauto.fahr(); // La Jamais Content macht brumm, brumm
elektroauto.lade(); // La Jamais Content wird geladen

Einschränkungen und Ausblick

Im Gegensatz zu komplexeren Pseudoklassen erlauben die obigen keine abgeleitete Methoden. Der Zugang zu einer gleichnamigen Methode in der Super-Klasse ist nicht so einfach möglich. Würde ein Elektroauto die Methode fahre überschreiben, so müsste diese die Super-Methode umständlich über Auto.prototype.fahr.call(this) aufrufen. Man könnte höchstens einen Shortcut namens superPrototype anlegen, sodass man this.superPrototype.fahr.call(this) schreiben könnte.

Komplexere Pseudoklassen wie die in Prototype und Mootools ermöglichen ein vereinfachtes Aufrufen der überschriebenen Super-Methoden, inklusive Super-Konstruktoren. Dies ist allerdings mit größerem Aufwand verbunden. In der Regel werden überschreibende Methoden in eine weiteren Funktion eingepackt, siehe etwa John Resigs Simple JavaScript Inheritance. In dieser Wrapper-Funktion wird eine temporäre Eigenschaft _super bzw. parent (Mootools) angelegt, welche auf die überschriebene Funktion zeigt. In Prototype gibt die Wrapper-Funktion die überschriebene Methode als ersten Parameter $super an die überschreibende Methode. Dazu muss sie das Funktionsobjekt umständlich in einen String umwandeln und die Parameterliste parsen.

Die Meta-Sprache CoffeeScript löst diese Aufgabe, indem es Super-Aufrufe automatisch so umschreibt, dass sie auf die gleichnamige Methode im Super-Prototyp verweisen (beispielsweise Elektroauto.__super__.method.apply(this, arguments), wobei Elektroauto.__super__ auf Auto.prototype zeigt). Dieser Ansatz gefällt mir am besten, denn die Wrapping-Komplexität, mit der sich Prototype und Mootools diesen »Syntaktischen Zucker« erkaufen, steht meines Erachtens in keinem Verhältnis zum Nutzen. Der von CoffeeScript erzeugte JavaScript-Code ist zwar ausufernd und gänzlich ohne »Zucker«, dafür ist er äußerst präzise und performant.

In klassenbasierter OOP gibt es meist so etwas wie private Eigenschaften und Methoden. Dies lässt sich in JavaScript nur funktional mit Closures lösen, siehe Private Objekte anstatt private Eigenschaften. Wenn im Konstruktor lokale Variablen oder Funktionen notiert werden, so sind diese nur denjenigen »öffentliche« Methoden zugänglich, welche im selben Konstruktor erzeugt werden. Ein Teilen privater Objekte zwischen Konstruktoren ist daher nicht möglich.

Fazit

Argo Brougham, 1912

Quelle: Wikipedia Commons, gemeinfrei

Die vorgestellte Methode ist nicht neu, sondern wird so oder ähnlich breit eingesetzt. Beispielsweise arbeiten Y.extend() aus Yahoo UI sowie die Pseudoklassen von CoffeeScript auf diese Weise.

Zusammengefasst können wir uns merken: Pseudoklassen in JavaScript sind Funktionen, die mit new als Konstruktoren verwendet werden. Vererbung zwischen Pseudoklassen bedeutet, dass der Prototyp der Sub-Klasse (im Beispiel Elektroauto.prototype) den Prototyp der Super-Klasse (im Beispiel Auto.prototype) als Prototyp besitzt. Vererbung bedeutet immer, dass einfache Objekte per Prototyp-Kette an andere einfache Objekte delegieren. Die Methode Object.create(), mit der sich eine solche prototypische Delegation einfach aufsetzen lässt, ist daher das Herzstück von JavaScript – auch pseudoklassische Vererbung kommt nicht ohne diese Technik aus.

Das folgende Diagramm veranschaulicht die Prototyp-Kette des elektroauto-Objekts. Darin kommen beide Pseudoklassen-Prototypen vor, die einander als Prototyp referenzieren. Am Ende steht wie immer der oberste Prototyp, Object.prototype.

elektroauto

[[Prototype]]Elektroauto.prototype
name'La Jamais Content'

Elektroauto.prototype

[[Prototype]]Auto.prototype
constructorElektroauto
ladefunction () { … }

Auto.prototype

[[Prototype]]Object.prototype
constructorAuto
fahrfunction () { … }

Object.prototype

[[Prototype]]null
constructorObject
toString[native Funktion]
toLocaleString[native Funktion]
valueOf[native Funktion]
hasOwnProperty[native Funktion]
isPrototypeOf[native Funktion]
propertyIsEnumerable[native Funktion]

Dank der prototypischen Delegation stehen bei der elektroauto-Instanz sämtliche Eigenschaften der im Diagramm gezeigten Objekte zur Verfügung.