JavaScript: Kapselung und Vererbung (Teil 2) Ein (schmerzhaftes) Eingeständnis

JavaScript: Kapselung und Vererbung (Teil 1)

Published on Thursday, June 19, 2014 3:00:00 PM UTC in Programming

Eines der bisher weitgehend unverstandenen Details an JavaScript für mich war das Thema Kapselung und Vererbung. Um den gravierendsten Problemen zu entgehen, vermied ich schon lange Zeit globale Variablen und Funktionen, verstand aber die dazu genutzten Techniken nie wirklich: mal definierte ich innere Funktionen in Konstruktoren, mal gab ich anonyme Objekte mit Funktionen per Closures zurück, mal schraubte ich an der ominösen Prototype-Property herum, bis das Ergebnis meinen Vorstellungen entsprach. Es musste dringend Klärung her, was ich da eigentlich jahrelang wirklich getan hatte.

Update: zur besseren Verdeutlichung der Sachverhalte habe ich nachträglich diverse Formulierungen erweitert und die Reihenfolge einzelner Abschnitte getauscht.

Hilfsmittel

Wer die hier gezeigten Beispiele einfach und schnell nachvollziehen möchte, dem seinen folgende Werkzeuge ans Herz gelegt:

  • JsFiddle: erlaubt es, online ad hoc Beispiel-Code auszuführen und die Ergebnisse anzusehen. Tipp: mit einer Zeile "debugger;" im JavaScript kann man das Anhalten der Ausführung im Browser erzwingen.
  • Browser-Tools: egal ob Internet Explorer, Chrome oder Firefox (ggfs. mit Firebug), jeder moderne Browser kann JavaScript debuggen, Objekte inspizieren lassen und mehr. F12 ist dein Freund :).

Los geht's!

Grundlagen

Ich werde mich an dieser Stelle nicht an der x-ten Einführung in Objektorientierung mit JavaScript versuchen, denn dafür habe ich nicht die Kompetenz. Außerdem haben das andere schon hunderte Male an anderen Stellen getan. Was ich aber erreichen möchte ist, eine kurze und knappe Darstellung der Situation und Möglichkeiten zu geben, so wie ich sie in meinem eigenen Lernprozess verstanden habe. Ich habe viel Aufwand investiert, um die Erläuterungen so akurat und zutreffend wie möglich zu machen, garantiere aber natürlich für nichts :-).

Für den Anfang sollte man sich, falls nicht schon früher geschehen, mit ein paar Eckdaten vertraut machen:

  • Funktionen sind "first class citizens" in JavaScript, insbesondere sind Funktionen Objekte. D.h. also, dass Funktionen Eigenschaften haben können, insbesondere auch geschachtelte weitere Funktionen.
  • Es gibt keine Klassen in JavaScript, nur Objekte.
  • Zugriffsrechte im Sinne von Kapselung können in JavaScript nur durch geschicktes Design des Codes und sehr eingeschränkt realisiert werden, da die dynamische Erweiterbarkeit ein bewusst gewünschtes Kern-Feature der Sprache ist.
  • Vererbung wird über ein Prototypen-Konzept realisiert, bei dem jede mittels eines Konstruktors erstellte Instanz die Eigenschaften dessen Prototyps "erbt". Das funktioniert über beliebig viele Hierachie-Ebenen, mithin wird also eine Prototypen-Kette gebildet.

Vieles davon ist für einen C-orientierten Entwickler (dazu zähle ich auch C++, C#, Java usw.) zunächst einmal schwer zu verdauen. Idealerweise akzeptiert man diese Punkte erstmal als gegeben und versucht sich immer wieder bewusst von seiner klassischen (nicht-funktionalen) Denkweise zu lösen, bis man die entsprechenden Konzepte soweit verinnerlicht hat, dass sie nicht mehr ein ständiges "Denk-Problem" beim täglichen Arbeiten sind.

Objekte

Ein Objekt in JavaScript kann Eigenschaften und Funktionen besitzen. Objekte können auf verschiedenen Wegen erstellt werden, beispielsweise über Initialisierer:

var myObject = { someProperty: "huhu" };
console.log(myObject.someProperty); // output: "huhu"

Das Besondere ist, dass Objekte jederzeit erweitert werden können, indem einfach weitere Eigenschaften oder Funktionen darauf definiert werden:

var myObject = { someProperty: "huhu" };
myObject.someOtherProperty = "hallo";
console.log(myObject.someOtherProperty); // output: "hallo"

Dabei ist es ein erwünschtes Feature, dass man bestehende Definitionen sowohl im Wert, als auch im Typ abändern kann:

var myObject = { someProperty: "huhu" };
console.log(typeof myObject.someProperty); // output: "string"
myObject.someProperty = 42;
console.log(typeof myObject.someProperty); // output: "number"

Außerdem kann man Eigenschaften und Funktionen auch wieder entfernen, was zunächst einmal seltsam anmuten kann:

var myObject = { someProperty: "huhu" };
delete myObject.someProperty;
console.log(myObject.someProperty); // output: "undefined"

Objekte sind also hochgradig veränderlich und dynamisch erweiter- und änderbar.

Funktionen

Die denkbar einfachste Definition einer Funktion könnte etwa so aussehen:

function test() {    
}

Was einem erstmal niemand erzählt ist, dass diese Funktionsdeklaration im Hintergrund eine Variable mit dem Namen der Funktion erstellt (im aktuellen Scope, also im Zweifelsfall global). Dieses Objekt repräsentiert die Funktion selbst und wird im Folgenden Funktionsobjekt genannt. Da es ein Objekt wie jedes andere ist, könnte man auch dieses dynamisch erweitern und verändern.

function test() {
}

console.log(typeof test); // output: "function"

test.newProperty = "huhu";
console.log(test.newProperty); // output: "huhu"

Um den Vorgang des Erstellens des Funktionsobjekts klarer herauszustellen, könnte man die Deklaration auch so formulieren:

var test = function test() {
}

console.log(typeof test); // output: "function"

Oder ganz kurz:

var test = function() {
}

console.log(typeof test); // output: "function"

Auch dies ist für viele eine gewöhnungsbedürftige Notation, an die man sich aber schnell gewöhnt.

Konstruktoren

Ein weiterer Weg, Objekte zu erzeugen, ist das Nutzen von Funktionen dafür. Das tut man mittels des Schlüsselworts "new", was die Funktion – wenn man so will – zu einem Konstruktor macht. Ein Konstruktor ist also eine ganz normale Funktion und wird erst durch eine spezielle Verwendung zum Konstruktor. Also:

var myFunction = function() {        
}

var myObject = new myFunction();

console.log(typeof myFunction); // output is still: "function"
console.log(typeof myObject); // output: "object"
console.log(myObject === myFunction); // output: "false"

Die letzten Zeilen demonstrieren, dass es sich wirklich um ein neues, separates Objekt handelt. Tatsächlich ist dieses Objekt aber im gezeigten Beispiel noch recht unnütz, da es "leer" ist und man nun wieder manuell Eigenschaften und Funktionen hinzufügen müsste. Um mehr Nutzen von Konstruktoren zu haben, muss man diese mit zusätzlicher Funktionalität versehen.

Zuvor aber ist es immens wichtig zu verstehen, was bei der Verwendung von "new" wirklich geschieht, weil das absolut nicht offensichtlich ist. Im Gegensatz zu Sprachen, in denen Objekte (Instanzen) aus Klassen erzeugt werden, passiert hier etwas völlig anderes. Genau genommen wird:

  • zunächst ein (völlig leeres) Objekt erstellt,
  • die Funktion "myFunction" aufgerufen und das neue Objekt als Kontext gesetzt,
  • damit der Konstruktor dieses neue Objekt nach Belieben manipulieren kann.

Tatsächlich ist es also nicht der Konstruktor, der das Objekt erzeugt; er initialisiert es nur, nachdem es bereits erzeugt wurde. Dieser Mechanismus wird durch das Schlüsselwort "this" ermöglicht, das ein weiteres großes Mysterium von JavaScript darstellt, welches nur schwer vollumfänglich zu verstehen ist (vielleicht später dazu mal mehr). Das Schlüsselwort "this" zeigt auf ein Objekt, das den aktuellen Kontext beschreibt, der dadurch bestimmt wird, wie die Funktion aufgerufen wird. Man kann sich diesen Kontext als Elternobjekt oder als Aufrufer vorstellen, aber er kann auch explizit gesetzt werden, beispielsweise durch die Verwendung der eingebauten JavaScript-Funktionen "call" und "apply". Beim Ausführen eines Konstruktors wird dieser Kontext also explizit auf das neu erzeugte, leere Objekt gesetzt und steht dann innerhalb des Konstruktors über das Schlüsselwort "this" zur Verfügung. Darüber kann man das neue Objekt manipulieren, ganz wie es im Abschnitt "Objekte" zu Beginn des Artikels gezeigt wurde. Also etwa so:

var myFunction = function() {
    this.someProperty = "huhu";    
}

var myObject = new myFunction();

console.log(myObject.someProperty); // output: "huhu"

Die Funktionsweise des obigen Beispiels kann man zur Verdeutlichung auch mit Bordmitteln nachbauen, ohne das Schlüsselwort "new" zu verwenden, mit sehr ähnlichem Ergebnis:

var myFunction = function() {
    this.someProperty = "huhu";    
}

var myObject = {}; // create an empty object
myFunction.call(myObject); // call myFunction, set the object as "this" context

console.log(myObject.someProperty); // output: "huhu"

Das unmittelbar sichtbare Ergebnis dieser beiden Varianten ist zunächst einmal dasselbe. Tatsächlich passiert aber hinter den Kulissen bei der Verwendung von "new" noch mehr; insbesondere wird der oben schon angesprochene Prototypen-Mechanismus korrekt initialisiert, was bei der zweiten, manuellen Variante so nicht passiert. Doch dazu später mehr.

Konstruktoren mit Rückgabewerten

Funktionen können natürlich auch einen Rückgabewert besitzen, und Konstruktoren sind da keine Ausnahme. Sobald eine Funktion einen Wert zurückgibt, wird dieser bei der Verwendung als Konstruktor als jenes Objekt verwendet, das mit "new" erstellt wird. Eventuelle Manipulationen des "this"-Kontexts sind dann wirkungslos und werden verworfen. Ein Beispiel:

var myFunction = function() {    
    this.someProperty = "wtf";    
    return { anotherProperty: "huhu" };
}

var myObject = new myFunction();

console.log(myObject.someProperty); // output: "undefined"
console.log(myObject.anotherProperty); // output: "huhu"

Wenn es lediglich darum geht, ein einziges Objekt mittels einer Funktion zu erstellen (statt potenziell vielen), dann sieht man oft die Variante, dass die Funktion direkt bei der Deklaration ausgeführt wird. Damit spart man sich syntaktisch den separaten Aufruf per "new".

var myObject = function() {        
    return { anotherProperty: "huhu" };
}();

console.log(myObject.anotherProperty); // output: "huhu"

Man beachte das ungemein wichtige Detail der Klammern und des Semikolons in Zeile 3, das die Ausführung der Funktion unmittelbar nach der Deklaration anstößt. Damit dürfte klar sein, dass das Ergebnis der Zuweisung nicht mehr das Funktionsobjekt ist (das ist verloren), sondern das Ergebnis des Funktionsaufrufs (entspricht also einem separaten "new"-Aufruf).

Wie gefährlich es werden kann, wenn man beispielsweise versehentlich beide Varianten mischt, zeigt dieses Beispiel:

var myObject = function() {        
    this.myProperty = "wtf";    
    return { anotherProperty: "huhu" };
}();

console.log(myObject.anotherProperty); // output: "huhu"
console.log(window.myProperty); // output: "wtf"

Da man hier unbedacht auf den "this"-Kontext zugegriffen hat, der aber nie per "new" auf das neu erstellte Objekt gesetzt wird, wird der aktuell gültige Kontext verwendet, in diesem Fall der globale Kontext (window). Dieser wurde um die Eigenschaft "myProperty" erweitert. Man kann sich vorstellen, dass solche Konstrukte zu subtilen Problemen führen können, wenn man versehentlich bereits existierende Eigenschaften überschreibt.

Private Eigenschaften von Funktionen

Bis hierhin waren alle Eigenschaften und Funktionen öffentlich. Das heißt, dass sie bei den erzeugten Objekten von außen zugreifbar und auch veränderlich sind. Tatsächlich kann man das aber gezielt verhindern, indem man das Schlüsselwort "var" verwendet:

var myFunction = function() {    
    var someValue = "wtf";
}

var myObject = new myFunction();
console.log(myObject.someValue); // output: "undefined"

Mit dem schon Diskutierten dürfte das keine Überraschung sein, denn "myFunction" ist als Konstruktor dafür zuständig, ggfs. Manipulationen auf dem "this"-Kontext (also dem neu erzeugten Objekt) vorzunehmen. Da das hier nicht getan wird, existiert später auch keinerlei Verbindung zwischen "myObject" und "someValue".

Geschachtelte Funktionen

Ebenso wie Eigenschaften können auch Funktionen genutzt werden. Hier ein Beispiel dazu, wie ein Konstruktor einem neuem Objekt eine Funktion hinzufügt, die dann über das Objekt verwendet werden kann:

var myFunction = function() {        
    this.myInnerFunction = function() {
        console.log("huhu");
    }
};

var myObject = new myFunction();
myObject.myInnerFunction(); // output: "huhu"

Und ebenso können aber auch private Funktionen definiert werden, die zwar innerhalb des Konstruktors verwendet werden können, von außen aber nicht zugreifbar sind:

var myFunction = function() {        
    var myInnerFunction = function() {
        return "huhu";
    }

    this.myProperty = myInnerFunction();
};

var myObject = new myFunction();
console.log(myObject.myProperty); // output: "huhu"
myObject.myInnerFunction(); // error: Object doesn't support property or method

Indirekter Zugriff auf private Member (Closures)

Ein interessantes Detail ist, dass öffentliche Funktionen ihrerseits problemlos auch zur Laufzeit private Member verwenden können:

var myFunction = function() {        
    var myPrivateFunction = function() {
        console.log("huhu");
    }

    this.myInnerFunction = function() {
        myPrivateFunction();
    }
};

var myObject = new myFunction();
myObject.myInnerFunction(); // output: "huhu"

Dies wird möglich durch das Konstrukt der sogenannten "Closures", durch den der gesamte nötige Geltungsbereich einer Funktion "für später" festgehalten wird. Auch dies ist eines der anfangs schwer zu verstehenden Details von JavaScript (obwohl es in C# seit einiger Zeit insbesondere im Zusammenhang mit Lamda-Expressions ähnliches gibt).

Konkret: die Funktion "myInnerFunction" benötigt für die korrekte Funktionsweise die private Funktion "myPrivateFunction", also wird diese in den Geltungsbereich eingeschlossen (daher "Closure"). Somit steht sie indirekt auch dem Objekt "myObject" zur Verfügung, obwohl diesem durch den Konstruktur nur "myInnerFunction" verliehen wurde. Der direkte Zugriff von außen auf "myPrivateFunction" ist aber weiterhin nicht möglich.

Zugriff von privaten auf "öffentliche" Funktionen

In bestimmten Situationen, beispielsweise im Zusammenhang mit Event-Handlern, möchte man von einer privaten Funktion aus eine jener Funktionen aufrufen, die man per "this"-Zugriff auf einem Objekt erzeugt hat. Das ist gar nicht so einfach, wie man an folgendem Beispiel sieht:

var myFunction = function() {        
    var myPrivateFunction = function() {
        var message = generateMessage(); // error: "generateMessage is undefined"
        console.log(message);
    }

    this.generateMessage = function() {
        return "huhu";
    }

    this.myInnerFunction = function() {
        myPrivateFunction();
    }
};

var myObject = new myFunction();
myObject.myInnerFunction();

Was passiert hier? Es wird ein neues Objekt mittels des Konstruktors "myFunction" erstellt. Beim Aufruf der erzeugten objekteigenen Funktion "myInnerFunction" wird die per Closure mitgenommene private Funktion "myPrivateFunction" aufgerufen. Diese möchte nun aber eine weitere Funktion auf "myObject", nämlich "generateMessage", aufrufen und scheitert daran. Die Funktion steht hier nicht zur Verfügung! Man ist nun also anscheinend in der kuriosen Situation, dass "myObject" zwar seine Funktion "generateMessage" selbst problemlos direkt aufrufen könnte, dasselbe aber beim Umweg über das Closure und "myPrivateFunction" nicht gelingt. Warum?

Das Problem liegt in der Art und Weise, wie der "this"-Kontext in JavaScript funktioniert. Beim qualifizierten Aufruf einer Funktion über ein Objekt (also als Methode des Objekts) wird "this" automatisch auf das Objekt selbst gesetzt. Daher führt "myObject.myInnerFunction()" dazu, dass "this" innerhalb von "myInnerFunction" auf "myObject" gesetzt ist. So weit, so gut. Der Aufruf von "myPrivateFunction" von dort aus ist aber nicht weiter qualifiziert, was dazu führt, dass der "this"-Kontext auf den globalen Kontext (hier: "window") gesetzt wird. Es findet also keine automatische Propagierung des Kontexts "myObject" auf diese Ebene statt, und dadurch kann auch die Funktion "generateMessage" nicht gefunden werden (sie wird auf "window" gesucht). Tatsächlich handelt es sich dabei um ein erwünschtes Verhalten im Zusammenhang mit der Prototypen-Vererbung, zu der wir später noch kommen werden. In der Praxis ist das aber einer der häufigsten Stolpersteine überhaupt.

Um die Problematik zu lösen, hat sich eine gängige Vorgehensweise etabliert: man speichert sich den Kontext zum Zeitpunkt der Ausführung des Konstruktors für die spätere Verwendung ab (zur Erinnerung: der Kontext bei der Ausführung des Konstruktors ist das gerade neu erstellte Objekt). Später qualifiziert man den Aufruf der fraglichen Funktion über diesen zwischengespeicherten Kontext und erzwingt damit deren korrekte Auflösung trotz fehlendem "this"-Kontext. Also etwa so:

var myFunction = function() {        
    var self = this; // store the context for later

    var myPrivateFunction = function() {
        var message = self.generateMessage(); // use the original context in "self"
        console.log(message);
    }

    this.generateMessage = function() {
        return "huhu";
    }

    this.myInnerFunction = function() {
        myPrivateFunction();
    }
};

var myObject = new myFunction();
myObject.myInnerFunction();

Auch hier sorgt übrigens der Closure-Mechanismus dafür, dass "myObject" beim (indirekten) Zugriff auf die private Funktion auch die Variable "self" zur Verfügung steht, die ironischerweise auf "myObject" selbst zeigt.

Hinweise

Mit den vorgestellten Mitteln kann man schon relativ weit kommen und eine für JavaScript recht saubere Kapselung hinbekommen, die auf jeden Fall robusteren und schöneren Code erlaubt.

Allerdings hat das noch wenig mit Vererbung zu tun. Alle vorgestellten Beispiele erzeugen bestenfalls durch den Aufruf von Konstruktoren "Klone" der in diesen definierten Funktionalitäten. Das "Klonen" kann man durchaus wörtlich nehmen: es handelt sich bei den gezeigten öffentlichen Eigenschaften und Funktionen der erzeugten Objekte immer um "Instanz-Member", die in jeder neuen Instanz dupliziert werden. Um das besser und effizienter zu tun und gleichzeitig das Thema Vererbung anzugehen, müssen wir einen Blick auf die Prototypen werfen – das nächste Mal :).

Weiterführende Links

Hier noch ein paar Artikel und Posts, die mir bei der Thematik weitergeholfen haben und sicher noch mehr Erhellung bringen können:

Tags: Funktionen · JavaScript · Kapselung · Vererbung