JavaScript/Tutorials/OOP/Klassen und Vererbung

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

In der objektorientierten Programmierung ist eine Klasse (man denke an „klassifizieren“ und „Klassifikation“, nicht an „Klassenzimmer“) eine Vorlage, die verwendet wird, um neue Objekte zu erstellen. Diese Objekte heißen dann Instanzen dieser Klasse - eine Übersetzung des englischen Wortes instance, dessen Bedeutungsspektrum breiter ist als der deutsche Begriff und hier für ein einzelnes Exemplar in einer Reihe gleichartiger Dinge steht. In klassenbasierenden Programmiersprachen sind Objekte grundsätzlich Instanzen einer Klasse, in JavaScript muss man etwas genauer hinschauen. Instanzen einer Klasse sind auf jeden Fall Objekte, es gibt aber auch viele Objekte, die sich keiner Klasse zugeordnen lassen (außer vielleicht der generischen Basisklasse Object).

Klassen oder Prototypen?

Im vorhergehenden Artikel haben wir bereits Prototypen und Konstruktorfunktionen beschrieben. Objektorientierte Programmierung in JavaScript baut grundsätzlich auf Prototypen auf. Das Schlüsselwort class implementiert lediglich einen Mechanismus, der die aus anderen Sprachen bekannte Syntax, mit der Klassen und Vererbung deklariert werden, in das Prototypenmodell von JavaScript übersetzt. Solche Konzepte sind auch als syntaktischer Zucker bekannt. Es gibt allerdings auch zwei neuere JavaScript-Features, die sich nur mit der class-Syntax realisieren lassen (private Eigenschaften und statische Initialisierer).

class

Eine Konstruktorfunktion dient zusammen mit dem new-Operator dazu, neue Objekte zu erzeugen, ihnen einen Prototypen zuzuordnen (über die prototype-Eigenschaft der Konstruktorfunktion) und den neuen Objekten Eigenschaften zuzuordnen. Vor ECMAScript 2015 erfolgte das relativ unsystematisch. Und sobald Vererbung hinzu kommt, ist dieser Vorgang auch fehleranfällig.

Eine reine Konstruktorfunktion lässt sich mit der class-Syntax so darstellen. Zum Vergleich haben wir die „klassische“ Syntax daneben gestellt:

Klassendeklaration
class Person {
   constructor(vorname, nachname, alter) {
      this._vorname = vorname;
      this._nachname = nachname;
      this._alter = alter;
   }
}
Konstruktorfunktion
const Person2 = function(vorname, nachname, alter) {
    "use strict";
    this._vorname = vorname;
    this._nachname = nachname;
    this._alter = alter;
}

Person und Person2 sind fast identisch: Eine Funktion, die zusammen mit new ein neues Objekt erzeugt und in deren prototype-Eigenschaft ein leeres Objekt zu finden ist.

Das Vergleichsbeispiel veranschaulicht drei wichtige Eigenschaften von Klassen:

  • eine Klassendeklaration erzeugt eine Programmkonstante, in der die Konstruktorfunktion zu finden ist
  • genau wie const-Deklarationen unterliegen Klassendeklarationen nicht dem Hoisting
  • Programmcode in Klassen wird grundsätzlich im strict mode ausgeführt.

Vielleicht fragen Sie sich, warum wir die Namen der erzeugten Eigenschaften mit einem Unterstrich beginnen lassen. Hier handelt es sich um eine oft benutzte Namenskonvention, die anzeigt, dass diese Eigenschaft nicht zur allgemeinen Verwendung freigegeben ist. Solche Eigenschaften nennt man auch privat. Ursprünglich kannte JavaScript keine (performante) Möglichkeit, den öffentlichen Zugriff auf Objekteigenschaften zu verhindern und war deshalb auf solche Namenskonventionen angewiesen. Wie man echte private Eigenschaften erzeugt, zeigen wir etwas später.

Wie Sie mit typeof überprüfen können, ist Person eine Funktion; das instanziierte Objekt anna jedoch ein Objekt.

Öffnen Sie die Konsole mit F12.

Klassendeklaration mit Konstruktor-Funktion ansehen …
const anna = new Person('Anna', 'Mustermann', 31);
    
console.log("typeof Person = "+typeof Person);
console.log("typeof anna = "+typeof anna);

Die Parameter der Konstruktor-Methode können beliebig genannt werden und sind unabhängig von den erzeugten Objekteigenschaften.

Methoden definieren

Einfache Methoden

Um eine Klasse funktionsfähig zu machen, benötigt sie außer einem Konstruktor auch Methoden. Wie eine Methodendefinition aussieht, haben Sie bereits gesehen: constructor ist eine Methode. Wir fügen unserer Personenklasse nun eine Methode hinzu, die eine Beschreibung der Person erzeugt. Eine Methodendefinition in einer class-Definition sieht genauso aus wie die vereinfachte Methodendefinition in einem Objektliteral. Was die Parameter betrifft, gibt es die gleichen Möglichkeiten wie bei Funktionsdefinitionen.

Person-Objekt mit einer Methode ansehen …
class Person {
  constructor(vorname, nachname, alter) {
  	this._vorname = vorname;
  	this._nachname = nachname;
  	this._alter = alter;
  }
  getBeschreibung() {
    return `Mein Name ist ${this._vorname} ${this._nachname} und ich bin ${this._alter} Jahre alt!`;
  }
  istFamilie(nachname) {
    return this._nachame == nachname;
}

const frizzi = new Person('Frizzi', 'Frisch', 15);
    
console.log(frizzi.getBeschreibung());

Die Darstellung des Konstruktors als spezielle Methode, der gleichrangig mit den übrigen Methoden der Klasse steht, ist ein aus anderen Programmiersprachen vertrautes Konzept und sehr gut lesbar.

Hinweis:
Diese Darstellung ist nützlich, aber dennoch eine Täuschung. Die Wahrheit sieht immer noch so aus, wie im vorigen Artikel unter Prototypen beschrieben:
  • Der Inhalt der constructor-Methode wird zum Inhalt der Konstruktorfunktion
  • Die übrigen Methoden werden zu Methoden des Prototyp-Objekts.

Getter und Setter

Weil unsere Person-Klasse ihre Eigenschaften als privat ansieht, sollten Verwender dieser Klasse das auch respektieren. Damit sie das können, muss Person Methoden bereitstellen, die den Wert dieser Eigenschaften bereitstellen. Solche Methoden nennt man Getter. Für Eigenschaften, deren Wert von außen auch verändert werden können soll, benötigt man Änderungsmethoden, die als Setter bezeichnet werden.

Getter und Setter kann man wie normale Methoden programmieren:

Person-Objekt mit weiteren Methoden ansehen …
class Person {
  constructor(vorname, nachname, alter) {
  	this._vorname = vorname;
  	this._nachname = nachname;
  	this._alter = alter;
  }
  getBeschreibung() {
    return `Mein Name ist ${this._vorname} ${this._nachname} und ich bin ${this._alter} Jahre alt!`;
  }
  getAlter() {
    return this._alter;
  }
  setAlter (alterNeu) {
    if (alterNeu < 0)
       throw new RangeError("Das Alter darf nicht negativ sein");
    this._alter = alterNeu;
  }
}

Außer der Lese-Methode getAlter wird auch die Methode setAlter angeboten, um das Alter einer Person zu ändern. Der Vorteil einer Setter-Funktion ist, dass auf Werteänderungen sofort reagiert werden kann, und sich auch prüfen lässt, ob der neue Wert sinnvoll ist. Im Beispiel wird ein negatives Alter damit quittiert, dass ein RangeError ausgelöst wird.

Der Nachteil dieser Vorgehensweise ist, dass die Nutzer einer solchen Klasse stets Methodenaufrufe programmieren müssen, was mehr Schreibarbeit ist. Es ist viel bequemer, frizzi.alter zu schreiben, als frizzi.getAlter(). JavaScript hat das passende Zückerchen dafür bereit. Um eine Eigenschaft bereitzustellen, deren Wert über eine getter-Methode geliefert wird, schreiben Sie get und den Eigenschaftsnamen. Ein leeres Klammernpaar ist – da es sich technisch um eine Methode handelt – immer noch erforderlich.

Eine setter-Methode erstellen Sie analog über set und Eigenschaftsnamen. Den Parameternamen, über den Sie den zuzuweisenden Wert erhalten, notieren Sie so wie Funktionsparameter.

Person-Objekt mit weiteren Methoden ansehen …
class Person {
  constructor(vorname, nachname, alter) {
  	this._vorname = vorname;
  	this._nachname = nachname;
  	this._alter = alter;
  }
  get beschreibung() {
    return `Mein Name ist ${this._vorname} ${this._nachname} und ich bin ${this._alter} Jahre alt!`;
  }
  get alter() {
    return this._alter;
  }
  set alter(alterNeu) {
    if (alterNeu < 0)
       throw new RangeError("Das Alter darf nicht negativ sein");
    this._alter = alterNeu;
  }
}

const erna = new Person("Erna", "Klein", 7);
erna.alter = 17;
console.log(erna.beschreibung);   // Mein Name ist Erna Klein und ich bin 17 Jahre alt!

Getter und Setter sind nicht erst mit der Klassensyntax eingeführt worden. Sie stammen aus ECMAScript 5, der Vorversion von ECMAScript 2015, und verwenden das Feature der PropertyDescriptoren. Sie können diese Syntax auch verwenden, um in einem Objektliteral getter oder setter zu implementieren.

Methoden für Events

Wenn Sie DOM Events durch eine Methode behandeln wollen, müssen Sie aufpassen.

Registrieren einer Methode als EventListener - falsch
class ClickController {
   constructor(element) {
      this.clickCount = 0;
      element.addEventListener("click", this.handleClick);
   }
   handleClick(clickEvent) {
      this.clickCount++;               // Fehler!
   }
}

Das DOM weiß nichts davon, dass Sie eine Methode übergeben haben, und es weiß auch nicht, zu welchem Objekt diese Methode gehört. Statt dessen ruft es die übergebene Listener-Funktion so auf, dass this auf das Objekt zeigt, auf dem der EventListener registriert wurde (das currentTarget des Events).

Da Sie das currentTarget ohnehin über das Event-Objekt erhalten, können Sie die bind-Methode verwenden, die jede Funktion anbietet, um den this-Wert für den Aufruf des EventListeners festzulegen:

Registrieren einer Methode als EventListener - richtig
class ClickController {
   constructor(element) {
      this.clickCount = 0;
      element.addEventListener("click", this.handleClick.bind(this));
   }
   handleClick(clickEvent) {
      this.clickCount++;               // Jetzt zeigt this auf das richtige Objekt
   }
}

Alternativ können Sie auch die althergebrachte Technik eines EventListener-Objekts verwenden und darin das this-Objekt aufbewahren.

Person-Objekt mit einer Methode für Events
class Person {

  constructor (name, alter) {
    this._name = name;
    this._alter = alter;
    // object to handle event:
    this.click = {handleEvent:this.hit, self:this};
  }

  vorstellen () {
    return "Ich heiße " + this._name + " und bin " + this._alter + " Jahre alt.";
  }

  alter (value) {
    this._alter = value;
  }

  hit(event) {
    var classThis = this.self;
    return "Ich bin " + classThis._alter + " Jahre alt.";
  }
}

In der Methode hit zeigt this auf das Objekt this.click Um die Methode hit aufzurufen, wird dem HTML-Objekt mit der Id "alter" ein EventListener hinzugefügt.

var p = new Person('Frizzy', 15);
var object = document.getElementById("alter");
object.addEventListener("click",p.click);

Ein direkter Aufruf der Methode p.hit führt zu einer Fehlermeldung.

Vererbung mit extends

Man kann Klassen zueinander in Beziehung setzen, indem man das Modell der Vererbung anwendet: Eine Klasse vom Typ B kann die Objektstruktur einer Klasse vom Typ A erben und für sich erweitern. Damit bauen Objekte vom Typ B auf der Objektstruktur von Typ A auf.

Klassenmodell mit Vererbung ansehen …
class Person {
  constructor(vorname, nachname, alter) {
    this._vorname = vorname;
    this._nachname = nachname;
    this._alter = alter;
  }
  information() {
    return (`Mein Name ist ${this._vorname} ${this._nachname} und ich bin ${this._alter} Jahre alt!`);
  }
}
class Schüler extends Person {
  tuWas () {
    return "keine Lust";
  }
}
class Lehrer extends Person {
  constructor(vorname, nachname, alter, fächer) {
    super(vorname, nachname, alter);
    this._fächer = fächer;
  }
  information() {
    return super.information() + " Ich unterrichte " + this._fächer;
  }
  tuWas () {
    return "korrigiere Klausur";
  }
}

const frizzy = new Schüler('Frizzy', 'Frisch', 15);
const louie = new Lehrer('Willi', 'Weißviel', 41, ['Mathematik', 'Informatik' ]);
console.log(frizzy.information()); 
console.log(louie.information()); 
console.log(frizzy.tuWas()); 
console.log(louie.tuWas());

In diesem Beispiel werden drei Klassen definiert, wobei die erste Klasse (Person) für die beiden folgenden Klassen eine Basis bildet, von der sie erben werden.

Die Klassen Schüler und Lehrer übernehmen („erben”) die Objektstruktur von Person mithilfe des Schlüsselwortes extends (englisch für erweitert). Das ist der Grund, warum die Methode information in den Klassen Schüler und Lehrer ebenso verfügbar ist. Zusätzlich fügen beide jeweils noch eine eigene Methode hinzu, die hier in beiden Fällen tuWas lautet, jedoch unterschiedliche Funktionalität hat.

Zugriff auf die Basisklasse mit super

Vor allem in der Konstruktorfunktion ist es wichtig, dass auch der Konstruktor der verwendeten Basisklasse aufgerufen wird. Dieser Aufruf muss explizit programmiert werden. Ohne den Aufruf des Basisklassenkonstruktors wirft JavaScript einen Error, wenn in der Konstruktorfunktion auf this zugegriffen wird.

Für diesen Aufruf kann aber nicht die new-Syntax verwendet werden, denn das Objekt ist bereits vorhanden. Statt dessen stellt JavaScript das Schlüsselwort super bereit[1]. Verwendet man es wie eine Funktion, ruft JavaScript die Konstruktorfunktion der Basisklasse auf. Ein Beispiel dafür sehen Sie im vorherigen Beispiel in der Lehrer-Klasse. Diese Verwendung von super ist allerdings nur in der constructor-Methode zulässig.

Ein weiterer Bedarf für das super-Schlüsselwort entsteht, wenn Ihre Klasse Methoden der Basisklasse überschreibt. Schauen Sie sich dazu die information()-Methode der Lehrer-Klasse an. Für einen Lehrer sollen nicht nur Name und Alter, sondern auch seine Fächer ausgegeben werden. Den Code der information()-Methode einfach zu kopieren wäre sehr unschön. Wird super an Stelle von this verwendet, so überspringt JavaScript bei der Suche nach Eigenschaften das aktuelle Objekt und dessen direkten Prototypen, und beginnt direkt beim Prototypobjekt der Basisklasse. Diese Verwendung von super ist im Konstruktor, in Methoden und auch in Objektliteralen zulässig, aber nicht in Funktionen.

Private Eigenschaften und Methoden

Die ECMAScript Sprachversion 2022 führt die Deklaration von echten privaten Klassenelementen ein. Dazu muss dem Namen der Eigenschaft oder Methode ein # vorangestellt werden. Dies ist das erste Feature der class-Syntax, das sich nicht anderweitig realisieren lässt.

Beispiel
class Foo {
   #geheim;
   #geheimWert = 7;
   #setzeGeheim(x) {
      this.#geheim = x;
   }
}

const f = new Foo();
// Alle nachfolgenden Zeilen schlagen fehl, weil sie auf private Elemente zugreifen!
console.log(f.#geheim);
console.log(f.#geheimWert);
f.#setzeGeheim(99);

statische Elemente einer Klasse

In der klassenbasierenden objektorientierten Programmierung unterscheidet man zwischen den Eigenschaften und Methoden der Klasseninstanzen - also den mit new erzeugten Objekten - und statischen Eigenschaften und Methoden. Statische Elemente einer Klasse lassen sich nutzen, ohne dass ein Objekt erzeugt wurde, und vor allem gibt es die statischen Eigenschaften nur einmal, ganz gleich, wie viele Instanzen der Klasse erzeugt wurden.

Technisch bedeutet das, dass statische Elemente einer Klasse von JavaScript als Eigenschaften und Methoden der Konstruktorfunktion realisiert werden. Da Funktionen in JavaScript ganz normale Objekte sind, ist das ohne weiteres möglich. Allerdings bedeutet das auch, dass Sie keine statischen Elemente definieren sollten, die Teil von Function.prototype sind. Ein statisches Element mit dem Namen prototype zu definieren führt sogar dazu, dass JavaScript sofort einen SyntaxError wirft.

Statische Eigenschaften einer Klasse dienen vor allem dazu, dass sich alle Objekte dieser Klasse einen Datenbereich teilen können. Beispielsweise könnte eine Klasse, die Daten vom Server bereitstellt, in einer statischen Map einen Cache implementieren, um gleiche Daten nur einmal laden zu müssen.

Statische Methoden sind für Funktionen interessant, die in einem inhaltlichen Zusammenhang mit einer Klasse stehen, aber nicht auf Instanzen der Klasse operieren. Dies können Fabrikmethoden sein (die Instanzen der Klasse erzeugen und initialisieren) oder auch Suchfunktionen, die die vorhandenen Instanzen durchsuchen (wofür natürlich eine statische Eigenschaft gebraucht wird, die die vorhandenen Instanzen sammelt).

Darüber hinaus werden statische Methoden auch in Klassen verwendet, die gar nicht zum Erzeugen von Instanzen gedacht sind - so genannte statische Klassen. Die Math-Klasse mit Eigenschaften wie Math.PI und Methoden wie Math.sin() ist ein Beispiel dafür.

Sie können statische Eigenschaften und Methoden erzeugen, indem Sie in der class-Definition das Schlüsselwort static hinzufügen:

Beispiel
class StrangeMath {
  static PIE = 3;
  static sin(angle) {
    return angle / this.#RIGHT_ANGLE;     // sin(0) ist 0, sin(90) ist 1, passt doch :)
  }
  static #RIGHT_ANGLE = 90;
}
Beachten Sie: In einer statischen Methode verweist this auf die Konstruktorfunktion, deshalb können Sie mit this.PIE auf die statische Eigenschaft PIE zugreifen.

Statische Elemente können auch privat sein, dazu stellen Sie ihrem Namen das # voran.

Um aus einer Instanzmethode heraus auf statische Elemente zuzugreifen, müssen Sie über die Konstruktorfunktion gehen. Das folgende Beispiel zeigt eine Formenklasse, die die bisher erzeugten Formen registriert und für eine Kollisionsprüfung eine statische Methode anbietet. Ein einzelnes Shape kann diese Methode ebenfalls aufrufen und sich selbst als Prüfkandidat übergeben:

Beispiel
class DataObject {
  static shapes = [];
  static isCollision(shape) {
    return null; // stellen Sie sich hier eine Kollisionsprüfung vor
  }
  
  checkCollision() {
    return Shape.isCollision(this);
  }
}

Wenn Sie eine statische Eigenschaft initialisieren, kann der verwendete Wert auch berechnet werden. Die dafür verwendeten Daten müssen aber in dem Moment, wo die Klasse definiert wird, definiert sein, d. h. Sie können beispielsweise nicht auf eine statische Eigenschaft zugreifen, die erst später in der Klasse definiert wird.

Für komplexere Initialisierungsvorgänge können Sie ab ECMAScript 2022 einer Klasse einen statischen Codeblock hinzufügen. Sie haben in einem statischen Initialisierungsblock Zugriff auf alle privaten und öffentlichen statischen Elemente der Klasse.

Beispiel
class ClassWithStatic {
  static staticField;
  static #staticPrivate;
  static {
    this.staticField = 4711;   // this in statischer Methode verweist auf die Klasse, nicht die Instanz
    this.#staticPrivate = 42;
  }
}

Den Namen der Klasse explizit innerhalb der Klasse zu verwenden, um auf statische Elemente zuzugreifen, kann problematisch sein. In einer statischen Methode können Sie sich mit this behelfen, aber wenn Sie aus einer Instanzmethode heraus auf statische Methoden zugreifen wollen, so wie in der oben gezeigten checkCollision-Methode, gibt es kein Schlüsselwort, das Ihnen automatisch den statischen Kontext liefert (wie z.B. self:: in PHP). Gerade bei vererbten Methoden wird es nun knifflig.

Wenn Sie Ihre Prototypen sauber erzeugt haben, sprich: der constructor Eigenschaft des Prototypen die Konstruktorfunktion zugewiesen haben, dann können Sie über this.constructor die Konstruktorfunktion des Objekts abrufen, von der das Objekt erzeugt wurde:

Verwenden der richtigen Konstruktorfunktion
class Basis {
   function createNew() {
      return this.constructor();
   }
}
class SubklasseA extends Basis {
}
class SubklasseB extends Basis {
}

const a1 = new SubklasseA();
const a2 = a1.createNew();    // erzeugt ein neues SubklasseA Objekt

Beide Subklassen erben die createNew-Methode von der Basisklasse. Dadurch, dass createNew auf this.constructor zugreift, kann createNew ein Objekt von genau der Klasse erzeugen, in die die Methode vererbt wurde.

Getter und Setter

Mit get können Sie die Eigenschaft eines Objekts an eine Funktion binden, die aufgerufen wird, wenn die Eigenschaft angeschaut wird.

Beispiel
{get eigenschaft() { ... } }
{get [ausdruck]() { ... } }


Folgende Angaben sind möglich:

  • eigenschaft: Eigenschaft, die mit der angegebenen Funktion verbunden werden soll
  • ausdruck:

ToDo (weitere ToDos)

Abschnit hier ausbauen oder woanders?

--Matthias Scharwies (Diskussion) 10:59, 25. Apr. 2023 (CEST)



Prototypische Vererbung (ES3)

ToDo (weitere ToDos)

Dieser Abschnitt sollte mit dem oberen ES6-Abschnitt abgeglichen und gekürzt werden.

--Matthias Scharwies (Diskussion) 16:39, 14. Nov. 2021 (CET)

Vor dem Einsatz der class-Syntax in ES6 wurde die klassenbasierte Vererbung mithilfe von Konstruktoren (wie im letzten Kapitel vorgestellt) und prototypischen Objekten (kurz: Prototypen) realisiert. Mathias Schäfer stellte in seiner „Einführung in JavaScript“ 2006-2008 vor.[2]

Mit Object-Literalen und dem Revealing Module Pattern haben wir das gebaut, was in anderen Programmiersprachen Singleton oder Klasse mit statischen Methoden genannt wird. Ein solches Modul kommt nur einmal vor und kann nur einen internen Status haben. In vielen Fällen ist es jedoch sinnvoll, mehrere Instanzen (Exemplare) eines Objektes zu erzeugen.

Konstruktor-Funktionen (Konstruktoren)

EineKonstruktor-Funktion ist ein Erzeuger neuer Objekte. Sie ist jedoch keine besondere Sprachstruktur, sondern erst einmal eine ganz normale Funktion. Zu einem Konstruktor wird sie lediglich dadurch, dass sie mit dem Schlüsselwort new aufgerufen wird.

Wenn eine Funktion mit new aufgerufen wird, wird intern ein neues, leeres Object-Objekt angelegt und die Funktion im Kontext dieses Objektes ausgeführt. Das bedeutet, im Konstruktor kann das neue Objekt über this angesprochen werden. Darüber können ihm z. B. Eigenschaften und Methoden hinzugefügt werden.

Der Unterschied beim Konstruktor ist, dass auf diese Weise unzählige gleich ausgestattete Objekte, sogenannte Instanzen erzeugt werden können.

Konstruktorfunktion ansehen …
// Konstruktorfunktion
function Katze (name, rasse) {
    // Zugriff auf das neue Objekt über this,
    // Hinzufügen der Eigenschaften und Methoden
    this.name = name;
    this.rasse = rasse;
    this.pfoten = 4;
}
const maunzi = new Katze('Maunzi', 'Perserkatze');

Die dem Konstruktor übergebenen Parameter werden zu Eigenschaften des Instanzobjekts. Auch wenn verschiedene Katze-Instanzen abweichende Eigenschaftswerte haben können, so ist ihnen allen gemein, dass sie die Eigenschaften name und rasse besitzen. Neben diesen beiden gibt es eine feste Eigenschaft pfoten: Katzen haben (in der Regel) vier Pfoten.

Eigenschaften können nach dem Erzeugen neue Werte bekommen. Der Zugriff von außen auf die Objekteigenschaften erfolgt über das bekannte Schema instanzobjekt.member. Wie gesagt sind Objekte in JavaScript jederzeit änderbar und erweiterbar.

Beispiel
let maunzi = new Katze('Maunzi', 'Perserkatze');
alert(maunzi.name + ' ist eine ' + maunzi.rasse);
maunzi.rasse = 'Siamkatze';
alert(maunzi.name + ' ist neuerdings eine ' + maunzi.rasse);

Da diese Eigenschaften von außen zugänglich und schreibbar sind, handelt es sich um öffentliche Eigenschaften.

Prototypische Objekte (Prototypen)

In den obigen Beispielen haben wir dem neuen Objekt direkt im Konstruktor Eigenschaften und Methoden hinzugefügt. Das bedeutet, dass diese Objekte mit jeder Instanz neu angelegt werden. Das ist eine Möglichkeit, wie dem Objekt Funktionalität hinzugefügt werden kann. Sie ist unter anderem dann notwendig, wenn Konstruktor-Parameter an das Instanzobjekt kopiert werden, wie es im Beispiel mit this.name = name; getan wird.

Mit einer Funktion ist immer ein prototypisches Objekt (Prototyp) verknüpft. Es handelt sich um ein gewöhnliches allgemeines JavaScript-Objekt, wie wir es auch mit new Object() oder dem Object-Literal {} anlegen können. Dieser Prototyp enthält bei eigenen Funktionen zunächst nur die Eigenschaft constructor, worin sich ein Verweis auf die Funktion findet, zu der der Prototyp gehört. Auf diese Weise kann man zu jedem Objekt, das diesen Prototypen nutzt, die Konstruktorfunktion finden, mit der es erzeugt wirde.

Über die Eigenschaft prototype können wir ausgehend vom Funktionsobjekt auf den Prototypen zugreifen. Dieses Objekt können wir entweder erweitern oder mit einem eigenen Objekt ersetzen. Im folgenden Beispiel wird der Prototyp erweitert, indem eine Methode hinzugefügt wird:

Beispiel
function Katze () {}

Katze.prototype.miau = function () {
    alert("Miau!");
};

let maunzi = new Katze();
maunzi.miau();

Hier wird ein Funktionausdruck notiert und das Funktionsobjekt in Katze.prototype.miau gespeichert. Beim Prototypen wird also eine Eigenschaft namens miau angelegt. Darin steckt nun die neu angelegte Funktion. Wenn wir eine Katze-Instanz erzeugen, so besitzt sie eine miau-Methode.

Beachten Sie: Wenn Sie den Prototypen durch ein eigenes Objekt ersetzen, denken Sie daran, auch die constructor-Eigenschaft vorzusehen und mit einem Verweis auf die Konstruktorfunktion zu bestücken.

Vererbung vom Prototypen zur Instanz

Durch den Aufruf von new Katze wird, wie gesagt, ein zunächst leeres Objekt erzeugt. Der Prototyp des Konstruktors, Katze.prototype, wird dabei in die sogenannte Prototyp-Kette (Prototype Chain) des Objektes eingehängt. Dies ist eine geordnete Liste mit Objekten, die abgearbeitet wird, wenn auf eine Eigenschaft des Objektes zugegriffen wird.

Ein konkretes Beispiel: Wenn wir den Ausdruck maunzi.miau schreiben, dann arbeitet der JavaScript-Interpreter die Prototyp-Kette ab, um die Eigenschaft namens miau zu finden und damit den Ausdruck aufzulösen. Die Prototyp-Kette von maunzi hat folgende Einträge:

  1. maunzi – das Instanzobjekt selbst
  2. Katze.prototype – der Prototyp für alle Objekte, die mit dem Katze-Konstruktor erzeugt wurden
  3. Object.prototype – der Prototyp, der hinter allen Objekten steht

Das folgende Diagramm zeigt Objekte der Prototyp-Kette und listet deren Eigenschaften auf:

maunzi
[[Prototype]] Katze.prototype
constructor Katze
Katze.prototype
[[Prototype]] Object.prototype
miau function () { alert("Miau!"); }
constructor Katze
Object.prototype
[[Prototype]] null
constructor Object
toString [native Funktion]
toLocaleString [native Funktion]
valueOf [native Funktion]
hasOwnProperty [native Funktion]
isPrototypeOf [native Funktion]
propertyIsEnumerable [native Funktion]

Wenn wir maunzi.miau() notieren, dann wird mit der Suche nach dieser Methode im Objekt selbst begonnen. Das Objekt maunzi hat selbst keine Eigenschaften. Intern besitzt maunzi jedoch einen Verweis auf seinen Prototyp, das ist Katze.prototype. Dieser Verweis wird in der unsichtbaren Eigenschaft [[Prototype]] gespeichert – lediglich in einigen JavaScript-Engines ist er über die Eigenschaft __proto__ les- und schreibbar.

Über diesen Verweis schreitet der JavaScript-Interpreter zum Prototypen von maunzi, Katze.prototype. Dort wird er fündig, denn dort existiert eine Eigenschaft namens miau. Dies ist eine Funktion und sie kann mit dem Aufruf-Operator (…) ausgeführt werden.


Auf diese Weise stehen alle Eigenschaften eines Prototypen, im Beispiel Katze.prototype, auch beim Instanzobjekt zur Verfügung, im Beispiel maunzi. Dies ist das ganze Geheimnis hinter der prototypischen Vererbung. Wenn bei einem Objekt selbst die angeforderten Eigenschaften nicht gefunden wurde, so dienen die Objekte in der Prototyp-Kette als Fallback. Man spricht von einer Delegation (Übertragung, Weitergabe). Das Objekt gibt die Anfrage an seine Prototypen weiter.

Prototypische Vererbung funktioniert grundlegend anders als klassenbasierte Vererbung, denn JavaScript ist eine äußerst dynamische Sprache. Es gibt keine Klassen, die einmal deklariert werden und nach der Kompilierung unveränderlich sind. Über die Prototyp-Kette erben gewöhnliche Objekte von gewöhnlichen Objekten. Jedes Objekt kann der Prototyp eines anderen Objektes werden und »einspringen«, wenn es die angeforderte Eigenschaft nicht selbst bereitstellen kann.

Alle beteiligten Objekte einschließlich der Prototypen können zur Laufzeit beliebig verändert werden. Ein Prototyp ist also im Gegensatz zu einer Klasse kein fester Bauplan für immer gleiche Instanzen, sondern selbst ein beliebiges Objekt, an das Eigenschaftsanfragen delegiert werden. Deshalb ist der Begriff »Instanz« für das Objekt, welches new Katze erzeugt, letztlich irreführend. Wo es keine Klassen als rein abstrakten Bauplan gibt, sondern bloß flexible Objekte mittels Konstruktoren erzeugt werden und von Prototypen erben, so trifft dieser Begriff die Sache nicht. Er wird hier trotzdem der Einfachheit halber verwendet.

Prototypen verstehen: Die Glasplatten-Metapher

Eine hervorragende Veranschaulichung von Prototypen hat Robin Debreuil ausgearbeitet. Er schrieb 2001 ein Tutorial über objektorientierte Programmierung mit ActionScript 1.0 in Flash 5.[3] ActionScript war damals eine Sprache, die auf ECMAScript 3 aufbaute und damit in den Grundzügen mit JavaScript identisch war. Die Sprache ActionScript ist mittlerweile von prototypenbasierter auf klassenbasierte Objektorientierung umgestiegen – für JavaScript sind diese Erklärungen aber immer noch gültig und aufschlussreich.

Die Metapher beschreibt Objekte in der Prototyp-Kette als beklebte Glasplatten. Auf jeder Glasplatte sind farbige Zettel an bestimmten Positionen aufgeklebt. Diese Zettel entsprechen den Objekteigenschaften, die Positionen entsprechen Eigenschaftsnamen. Die Instanz selbst verfügt über ein paar Zettel, sein Prototyp und dessen Prototyp über weitere. Diese können auch an denselben Stellen kleben.

Die Funktionalität, über die die Instanz verfügt, ist nun eine Summe der Zettel der drei Glasplatten: In der Metapher werden die Glasplatten übereinandergelegt. Da sie durchsichtig sind, schimmern durch die Lücken die Zettel auf den darunterliegenden Platten durch. Das Gesamtbild, das sich so ergibt, setzt sich aus den Zetteln der drei Platten zusammen.

Glasplatten-Metapher

Das Objekt ist demnach ein Mosaik, das sich aus den eigenen sowie fremden Eigenschaften zusammensetzt. Welches Objekt in der Prototyp-Kette nun eine gesuchte Eigenschaft bietet, ist unwichtig. An der gesuchten Stelle auf dem Glas ist ein Zettel sichtbar – in der Grafik z. B. oben links, dieser entspricht einem Eigenschaftsnamen, z. B. eins.

Die Metapher zeigt auch, dass Objekte in der Prototyp-Kette gleiche Eigenschaften bieten können. An der Stelle oben in der Mitte klebt ein gelber Zettel auf der Instanz-Glasplatte, aber auch ein orangener, der die Konstruktor.prototype darstellt. Beim Übereinanderlegen ist nur der gelbe Zettel der oben liegenden Instanz-Glasplatte sichtbar, der orangene wird überdeckt. Übertragen heißt das: Eine Eigenschaft kann am Instanzobjekt definiert werden, und schon überschreibt sie eine gleichnamige Eigenschaft am prototype-Objekt des Konstruktors.

Diese Art der Vererbung nennt man Differential Inheritance. Im Gegensatz zur klassenbasierten Vererbung werden beim abgeleiteten Objekt (der Instanz) keine Eigenschaften erzeugt. Die Instanz ist keine Kopie des Prototypen, die Instanz kann sogar leer sein, wie es im Katzen-Beispiel der Fall ist. Erst wenn sich die Instanz vom Prototypen unterscheidet, wird bei der Instanz eine Eigenschaft angelegt, die einen anderen Wert als die des Prototypen besitzt. In der Glasplatten-Metapher bedeutet dies: An den Stellen, in denen die Instanz dem Prototyp gleicht, ist die Platte durchsichtig – sie delegiert. Wo sie sich unterscheidet, besitzt sie einen eigenen, andersfarbigen Zettel.

Nehmen wir an, dass hiesige Katzen meistens von der Rasse »Europäisch Kurzhaar« sind. Anstatt dies jedes Mal beim Erzeugen anzugeben, legen wir die Eigenschaft beim Prototypen an:


Beispiel
function Katze () {}
Katze.prototype.rasse = "Europäisch Kurzhaar";

let maunzi = new Katze();
alert(maunzi.rasse);

Greifen wir auf maunzi.rasse zu, so wird die rasse-Eigenschaft beim Prototypen gefunden. Denn die relevanten Objekte sehen so aus (Object.prototype wurde ausgeblendet):

maunzi
[[Prototype]] Katze.prototype
constructor Katze
Katze.prototype
[[Prototype]] Object.prototype
rasse "Europäisch Kurzhaar"
constructor Object

Wenn wir nun eine Katze mit abweichender Rasse erzeugen wollen, so legen wir eine gleichnamige Eigenschaft bei der Instanz an, die die Eigenschaft des Prototypen verdeckt:

Beispiel
let maunzi = new Katze();
alert(maunzi.rasse); // Derzeit noch »Europäisch Kurzhaar« - vererbt vom Prototypen

maunzi.rasse = "Perser";
alert(maunzi.rasse); // Jetzt »Perser« - eigene Eigenschaft


Daraufhin besitzt die Instanz eine eigene, abweichende Eigenschaft:

maunzi
[[Prototype]] Katze.prototype
rasse "Perser"
constructor Katze
Katze.prototype
[[Prototype]] Object.prototype
rasse "Europäisch Kurzhaar"
constructor Object

Private Objekte anstatt private Eigenschaften

Viele klassenbasierte Sprachen erlauben es, die Sichtbarkeit von Instanzeigenschaften festzulegen und unterscheiden beispielsweise zwischen öffentlichen und privaten Eigenschaften.

In JavaScript (ECMAScript 3) gibt es keine Möglichkeit, gewisse Eigenschaften eines Objektes als privat zu deklarieren, sodass sie ausschließlich in Methoden des Objektes zur Verfügung stehen. Sobald wir einem Objekt eine Eigenschaft hinzufügen, ist diese auch überall dort verfügbar, wo das Objekt verfügbar ist. Dagegen können Sie mit Object.freeze eine Eigenschaft als nicht überschreibbar und nicht löschbar deklarieren.

Beispiel
function Katze () {}

Katze.prototype.pfoten = 4;
Katze.prototype.miau = function () {
   alert("Miau!");
};

let maunzi = new Katze();
let schnucki = new Katze();

// Überschreibe vom Prototyp vererbte Eigenschaft,
// indem eine gleichnamige Eigenschaft bei der Instanz erzeugt wird:
maunzi.pfoten = 5;
alert('Maunzi hat nun ' + maunzi.pfoten + ' Pfoten.');

// Überschreibe Methode des Prototyps:
Katze.prototype.miau = function () {
   alert("Wau, wau!");
};
schnucki.miau();


Das obige Beispiel zeigt, wie ein Instanzobjekt und auch der Prototyp nachträglich verändert werden können: Plötzlich hat Maunzi fünf Pfoten und alle Katzen sagen »wau« anstatt »miau«. Dies ist sowohl ein Defizit von ECMAScript 3 als auch eine Stärke: Prototypen sind nicht abgeschlossen und Objekte immer erweiterbar.

Wie gesagt gibt es keine privaten Eigenschaften im Wortsinn, auch wenn manche diesen Begriff auch auf JavaScript anwenden. Denn wenn ein Objekt an der Instanz hängt, ist es in ECMAScript 3 auch notwendig nach außen sichtbar und unkontrolliert überschreibbar. Wir können jedoch einen Trick anwenden, den wir bereits vom Revealing Module Pattern kennen: In einem Funktions-Scope notieren wir lokale Variablen und zudem die öffentlichen Methoden des Objektes. Die öffentlichen Methoden haben auf erstere Zugriff, weil sie im selben Scope erzeugt wurden und damit Closures sind. Daher handelt es sich um sogenannte privilegierte Methoden.

Da der Konstruktor bereits einen Funktions-Scope bereitstellt, nutzen wir diesen für private Objekte. Damit die Methoden auf die privaten Objekte Zugriff haben, müssen sie im Konstruktor erzeugt und dürfen nicht über den Prototyp definiert werden. Über this werden sie ans Instanzobjekt gehängt.

Beispiel
function Katze (name) {
    // --- Private Objekte
    // Private Variablen
    let pfoten = 4;
    let gestreichelt = 0;
    // name ist ebenfalls eine private Variable
    
    // Private Funktionen
    function miau () {
        alert(name + ' macht miau!');
    }
    
    // --- Öffentliche (privilegierte) Eigenschaften
    this.name = name;
    // Öffentliche Methoden
    this.kitzeln = function () {
        alert(name + ' hat ' + pfoten + ' kitzlige Pfoten.');
        miau();
    };
    this.streicheln = function () {
        gestreichelt++;
        miau();
    };
}

let maunzi = new Katze('Maunzi');
maunzi.kitzeln();
maunzi.streicheln();

alert('maunzi.name: ' + maunzi.name);
// pfoten ist keine Objekt-Eigenschaft, also von außen unzugänglich:
alert('maunzi.pfoten: ' + maunzi.pfoten);

Der Konstruktor nimmt hier den Parameter name entgegen. Dieser ist automatisch eine lokale Variable. Zusätzlich werden zwei lokalen Variablen (pfoten, gestreichelt) sowie eine lokale Funktion (miau) angelegt. Der leeren Instanz werden eine Eigenschaft (name) und zwei öffentliche Methoden (kitzeln, streicheln) angehängt, die als verschachtelte Funktionsausdrücke notiert werden.

Nach dem Anlegen einer Katze mit dem Namen Maunzi werden die beiden Methoden aufgerufen. Sie haben Lese- und Schreibzugriff auf die privaten Objekte und können auch die private Funktion ausführen, welche von außen nicht zugänglich sind.

Wie das Beispiel zeigt, können auch private Funktionen angelegt werden. Private Funktionen können zum Beispiel interne, in verschiedenen Methoden verwendete Helfer sein. Sie entsprechen privaten Methoden in klassenbasierten Sprachen. Dieser Begriff ist auf JavaScript nicht anwendbar, da es sich eben nicht um Methoden des Instanzobjekt handelt. Sie sind lediglich in den tatsächlichen Instanzmethoden verfügbar, da diese Zugriff auf die Variablen des Konstruktor-Scopes haben.

Nachteile von privaten Objekten

Der gravierende Unterschied zu den vorigen Beispielen ist, dass die Nutzung des Prototyps und damit die Vererbung wegfällt. Anstatt die Methoden einmal am Prototyp zu erzeugen, werden sie bei jeder Instanz im Konstruktor von neuem angelegt. Heraus kommt folgende Prototyp-Kette:

maunzi
[[Prototype]] Katze.prototype
name Maunzi
kitzeln function() {…}
streicheln function() {…}
Katze.prototype
[[Prototype]] Object.prototype
constructor Object
Object.prototype
[[Prototype]] null
constructor Object
toString [native Funktion]
toLocaleString [native Funktion]
valueOf [native Funktion]
hasOwnProperty [native Funktion]
isPrototypeOf [native Funktion]
propertyIsEnumerable [native Funktion]

Der Prototyp ist demnach leer, die Eigenschaften hängen direkt an der Instanz und werden nicht vererbt. Dies hat verschiedene Konsequenzen:

Wenn Methoden über den Prototyp anstatt im Konstruktor erzeugt werden, wird Speicher eingespart. Dafür ist das Auflösen von Eigenschaften über die Prototyp-Kette etwas langsamer, da an mehreren Objekten gesucht wird.

Ein Überschreiben oder Löschen dieser Eigenschaften über den Prototyp ist nicht möglich. Ein nachträgliches Erweitern über den Prototyp ist möglich, diese Methoden sind allerdings nicht privilegiert, haben also keinen Zugriff auf private Objekte. Denn sie wurden nicht im Konstruktor definiert und schließen die privaten Objekte nicht ein. Aus demselben Grund ist kein nachträgliches Hinzufügen von privaten Objekten möglich.

Mit jedem Erzeugen einer Katze-Instanz werden alle Eigenschaften von neuem erzeugt. Die Instanzen teilen sich ihre Eigenschaften nicht mit anderen Katze-Instanzen. Dies wirkt sich negativ auf die Performance aus: Das Anlegen der Eigenschaften kostet Zeit und Arbeitsspeicher. Werden z. B. zehn Katzen instantiiert, so werden 20 Funktionsobjekte erzeugt (zehn mal kitzeln und streicheln). Würden die Instanzen diese Methoden vom Prototyp erben, so müssten sie nur einmal am Prototyp erzeugt werden.

Wozu Kapselung gut ist und wann sie nötig ist

Kapselung in objektorientiertem JavaScript hat zwei Bedeutungen: Zum einen wird das Programm so strukturiert, dass es möglichst nicht mit anderen Scripten in Konflikt kommt. Dafür muss man in JavaScript selbst sorgen, da sich alle Scripte das globale window-Objekt teilen. Zum anderen bedeutet Kapselung die Trennung zwischen öffentlichen und privaten Objekten. Auf die öffentlichen können andere Scripte von außen zugreifen, die Privaten sind diesen verborgen.

Die erste Art der Kapselung ist in jedem Fall sinnvoll, eine effektive Kapselung der zweiten Art bringt jedoch Nachteile mit sich. Wann also brauchen wir sie und in welchem Umfang?

Das Konzept der Kapselung in der objektorientierten Programmierung hat den Zweck, zwischen einer öffentliche API und der internen Implementierung zu unterscheiden. Sie hilft in aller erster Linie dem Programmierer, gut strukturierte, wiederverwendbare Software zu schreiben.

Die API, also das Set an öffentlichen Methoden, wird dokumentiert und von anderen Programmierern angesteuert. Sie bleibt idealerweise über mehrere Programmversionen gleich. Sie kann natürlich erweitert und beizeiten durch eine leistungsfähigere und flexiblere Schnittstelle ergänzt werden.

Die privaten Objekte umfassen alle Variablen und Methoden, die nur intern Gebrauch finden und die tatsächliche Implementierung strukturieren. Ein bloßer Anwender des Scriptes muss diese nicht kennen. Von Version zu Version kann sich die interne Umsetzung bei gleichbleibender Funktionalität ändern, damit auch die privaten Objekte.

Ein zweiter Grund für Kapselung ist die effektive Sicherheit: Ein fremdes Script soll die Interna nicht lesen und manipulieren können. Dies ist in anderen Sprachen sehr wichtig. Manche JavaScript-Kenner argumentieren, dass der Aspekt der tatsächlichen Unsichtbarkeit für JavaScript nachrangig ist:

  • Mit Tricks ist es bei einigen JavaScript-Interpretern möglich, auf einen privaten Funktions-Scope zuzugreifen.
  • Es kann durchaus von Vorteil sein, die Interna eines Scriptes lesen, abändern und erweitern zu können. Das gehört zu den Features von JavaScript.
  • Sie können einzelne Objekteigenschaften oder ganze Objekte mit Object.freeze »einfrieren« und vor Manipulation schützen.

Pseudo-private Objekte

Aus diesen Gründen kann es ausreichen, Kapselung lediglich als Strukturierungskonzept zu verstehen. Es reicht dann, mit pseudo-privaten Objekten zu arbeiten. Diese sind nicht effektiv vor dem Zugriff von außen geschützt, finden jedoch in der Regel nur intern Verwendung.

Ein einfaches Object-Literal bietet keine effektive Kapselung, d.h. alle Eigenschaften sind von außen sichtbar und änderbar. Kapselung als Konzept lässt sich damit trotzdem umsetzen, indem klar zwischen öffentlichen und (pseudo-)privaten Eigenschaften unterschieden wird. Eine Konvention ist etwa, die privaten Eigenschaften mit einem _ (Unterstrich) beginnen zu lassen. Ein entsprechendes Modul könnte folgendermaßen aussehen:

Beispiel
let Modul = {
    // Öffentliche Eigenschaften
    öffentlicheMethode : function () {
        alert(this._privateMethode());
    },
    // Pseudo-private Eigenschaften
    _privateEigenschaft : 1;
    _privateMethode : function () {
        this._privateEigenschaft++;
        return this._privateEigenschaft;
    }
};
Modul.öffentlicheMethode();

Dasselbe bei einem Prototypen:

Beispiel
function Konstruktor () {}

// Öffentliche Eigenschaften
Konstruktor.prototype.öffentlicheMethode = function () {
   alert(this._privateMethode());
};

// Pseudo-private Eigenschaften
Konstruktor.prototype._privateEigenschaft = 1;
Konstruktor.prototype._privateMethode = function () {
   this._privateEigenschaft++;
   return this._privateEigenschaft;
};

let instanz = new Konstruktor();
instanz.öffentlicheMethode();

Wie gesagt, technisch gesehen handelt es sich bei diesen pseudo-privaten Eigenschaften um ganz normale Eigenschaften, die sich in puncto Sichtbarkeit und Schreibbarkeit nicht von den sogenannten öffentlichen unterscheiden. Der Unterstrich im Namen hat keine Bedeutung für den JavaScript-Interpreter, er ist lediglich eine Namenskonvention.


ToDo (weitere ToDos)

  1. SELFHTML bemüht sich, Tutorials immer mit einem kontextualisierten Beispiel zu erklären.
    Wer entwickelt unser Beispiel weiter?
  2. Wie kann der ES3-Abschnitt gekürzt werden? Die Glasplattenmetapher sollte - wenn möglich - drin bleiben.
  3. Inwieweit ist Private_Objekte_anstatt_private_Eigenschaften schon in Module und Kapselung besprochen, bzw. sollte dort besprochen werden?

--Matthias Scharwies (Diskussion) 06:53, 23. Apr. 2023 (CEST)}}

Weblinks

Quellen

  1. MDN: super
  2. Bei diesem Kapitel handelt es sich um eine überarbeitete Übernahme aus der Einführung in JavaScript von Mathias Schäfer.
    Mathias Schäfer: Organisation von JavaScripten: Module und Kapselung
  3. debreuil.com: Building Object-Oriented Programming