Geschrieben von

Vererbung in JavaScript

MarTech/AdTech

Einführung

Vererbung (Inheritance) beschreibt ein Konzept, wo Merkmale eines Elements auf ein anderes Element übertragen werden. Damit lässt sich Code wiederverwenden und einsparen. In JavaScript bedeutet Vererbung, dass Eigenschaften und Methoden eines bestehenden Objekts auf ein anderes Objekt übertragen werden. In anderen Programmiersprachen kennt man dieses Konzept von Klassen (eine Klasse übergibt ihre Eigenschaften und Methoden einer anderen Klasse). JavaScript bietet jedoch zwei Möglichkeiten Vererbung zu erreichen:

  • Über Objekte, auch prototypische Vererbung genannt
  • Über Klassen, auch klassenbasierte Vererbung genannt

Wieso Vererbung in JavaScript nutzen?

Bevor ich auf die zwei Möglichkeiten eingehe, schauen wir zunächst an wieso Inheritance wichtig ist und welche Probleme es löst. Gehen wir von folgendem Code aus:

const car = {
   drive() {
      return "ok";
   }
};

Wir haben ein Objekt mit einer Methode. Neben “car” brauchen wir nun auch “bicycle”. Dieses Objekt soll ebenfalls diese Methode enthalten:

const car = {
   drive() {
      return "ok";
   }
};

const bicycle = {
   drive() {
      return "ok";
   }
};

Jetzt brauchen wir noch weitere Fahrzeuge:

const car = {
   drive() {
      return "ok";
   }
};

const bicycle = {
   drive() {
      return "ok";
   }
};

const motorcycle = {
   drive() {
      return "ok";
   }
};

const bus = {
   drive() {
      return "ok";
   }
};

An diesem einfachen Code-Beispiel kann man schon zwei Probleme erkennen:

  • Duplizierung: Die Methode “drive()” muss mehrfach geschrieben werden. In unserem einfachen Beispiel ist das zwar nur eine Zeile Code, in echten Projekten hat man meist komplexe Logiken innerhalb der Methode definiert. Man hätte also viel Copy-and-Paste-Arbeit.
  • Pflege:: Bei einem Bug innerhalb der Methode, müsste man den Fix an vielen Stellen durchführen. Wäre es nicht besser den Fix an nur einer Stelle durchzuführen?

Hier kommt dann die Vererbung ins Spiel.

Klassenbasierte Vererbung (Classical Inheritance)

Um das Prinzip der Vererbung mit Klassen zu zeigen erstellen wir zunächst eine Klasse:

class Vehicle {
  drive() {
    return "I am driving.";
  }
};

Mit diesem Blueprint können wir nun Objekte erstellen:

const car = new Vehicle();
const bus = new Vehicle();

Jetzt können wir die Methode “drive” auf beiden Objekten aufrufen und haben damit das ersten oben genannte Problem der Duplizierung gelöst:

car.drive(); // Gibt "I am driving." zurück
bus.drive(); // Gibt "I am driving." zurück

Wir haben damit die Methode nur einmal definiert, rufen sie aber mehrmals auf. Was wenn wir aber in unserer Methode einen Bug feststellen und diesen fixen möchten? Und zwar so, dass der Fix auch innerhalb der Objekte greift? Jetzt könnte man meinen, dass wir dies wie folgt machen könnten:

car.drive = ...

Theoretisch würde das auch gehen, jedoch würden wir damit nicht die Methode im Blueprint anpassen, sondern eine neue Methode (mit dem gleichen Namen) dem Objekt zuweisen. Der Hintergrund ist folgender. Wenn wir uns beispielsweise unser Objekt “car” anschauen, dann sehen wir:

“car” ist vom Typ “Vehicle”, hat aber keine drive-Methode. Woher kommt also die Methode, wenn wir “car.drive()” aufrufen? Hier ist die Antwort:

Wie wir sehen, gibt es dort einen Prototype und wir sehen ebenfalls unsere drive-Methode. Das heißt, dass “car” die drive-Methode nicht direkt besitzt, sondern über die Prototype-Kette die Methode des Blueprints vererbt hat. Wir können das auch testen indem wir folgendes ausführen:

car.maxSpeed = "100 km/h";

Wenn wir uns jetzt unser Objekt ansehen, haben wir folgendes Ergebnis:

Wir haben jetzt “maxSpeed” in unserem Objekt, aber die drive-Methode taucht nicht direkt auf, da sie eben vom Blueprint vererbet wurde. Wenn wir also eine Methode für alle abgeleiteten Objekte des Blueprints anpassen möchte, müssen wir über den Prototype gehen. Am besten macht man das direkt auf der Klasse selbst:

Vehicle.prototype.drive = function() {
  return "I am driving fast.";
};

Alternativ kann man das Update auch über “__proto__” machen. Vergleicht man “prototype” und “__proto__”, wird man feststellen, dass beides dasselbe sind:

Vehicle.prototype === car.__proto__ // Gibt "true" zurück

Jedoch sollte man aufgrund von Kompatibilitäts-, Lesbarkeit- und Sicherheitsgründen von “__proto__” abraten. Was man feststellen kann, ist, dass man trotz der Nutzung von Klassen und dem class-Keyword in JavaScript mit Objekten arbeitet. Deshalb sind Klassen in JavaScript auch nur ein syntaktischer Zucker.

Ein weiteres wichtiges Konzept der Vererbung ist das extends-Keyword. Dazu habe ich in meinem Beitrag “Einführung in Klassen in JavaScript” geschrieben.

Prototypische Vererbung (Prototypal Inheritance)

Kommen wir zur prototypischen Vererbung mit Objekten. Wir haben oben gesehen, wie die klassenbasierte Vererbung funktioniert. Was JS dabei im Grunde macht (sprich mit der Nutzung des class-Keywords), ist es eine Funktion names “Vehicle” zu erstellen (das ist dann auch die Konstruktor-Funktion):

function Vehicle() {};

Im Anschluss setzt JS die von uns definierten Methode über die Prototype-Kette (darüber kann auch die Methode aktualisiert werden):

Vehicle.prototype.drive = function() {
  return "I am driving.";
};

Danach können wir unsere Instanzen wie gewohnt erstellen:

const car = new Vehicle();
const bus = new Vehicle();

Damit haben wir die Methode “drive()” nicht direkt innerhalb der Instanzen, sondern im Blueprint. Dies erspart uns Code-Duplizierung und erleichtert uns die Pflege. Man muss jedoch aufpassen, wenn man die Methode direkt innerhalb der Funktion “Vehicle” setzt. Wie soeben beschrieben sollte die prototypische Vererbung mit Objekten wie folgt umgesetzt werden:

function Vehicle() {};

Vehicle.prototype.drive = function() {
  return "I am driving.";
};

const car = new Vehicle();

Schauen wir uns nun “car” an, dann sehen wir, dass “drive()” im Prototype gesetzt ist:

Macht man jedoch folgendes, dann führt das zu Nebeneffekten, denen man sich bewusst sein sollte:

function Vehicle() {
  this.drive = function() {
    return "I am driving.";
  };
};

Wir setzen hier die drive-Methode also nicht über den Prototype, sondern direkt innerhalb der Konstruktor-Funtion. Erstellen wir nun eine Instanz:

const car = new Vehicle();

Die definierte Methode können wir ebenfalls über “car.drive()” nun aufrufen. Erstellen wir aber noch eine Instanz:

const bus = new Vehicle();

Auch hier würde der Aufruf “bus.drive()” funktionieren. Was ist aber hier anders? Schauen wir “car” näher an, dann fällt folgendes auf:

Unsere Methode ist nicht mehr im Prototype zu finden, sondern sitzt direkt auf der Instanz. Jedes Mal wenn wir eine neue Instanz erstellen, wird diese Methode mitkopiert. Stelle dir vor, wir haben mehrere Instanzen und müssen nun die Logik innerhalb der Methode ändern. Folgende Angabe würde nicht zum gewünschten Ergebnis führen:

Vehicle.prototype.drive = function () {
    return "I am driving fast.";
};

Da die initial definierte Methode “drive” direkt auf der Instanz erstellt worden ist, würde dieser Code dazu führen, dass eine weitere Methode “drive” im Prototype gesetzt wird:

Wir sehen hier also den feinen Unterschied zwischen dem Setzen der Methode über den Prototype vs. dem Setzen der Methode über das this-Keyword. Letzters führt dazu, dass die Methode nicht als Methode angesehen wird, sondern als Eigenschaft. Die Nutzung des this-Keywords führt in diesem Fall dazu, dass die drive-Methode über den Konstruktur einmalig beim Erstellen des Objekts auf dem Objekt direkt gesetzt wird und nicht im Blueprint vorhanden ist. Folgendes einfaches Beispiel verdeutlicht das nochmal:

function Vehicle() {
  this.power = "400PS";
};

const myCar = new Vehicle();

Die “400PS” bekommen wir über “myCar.power”, aber nicht über “Vehicle.power”:

myCar.power // Gibt "400PS" zurück
Vehicle.power // Gibt "undefined" zurück

Daher sollte man – was auch üblich bei prototypischer Vererbung ist – alle Eigenschaften der Klasse über den Konstruktur setzen…

function Vehicle() {
  this.power = "400PS";
  this.color = "Blue";
  this.speed = 320;
  this.tuned = false;
}; 

…und alle Methoden über den Prototype des Blueprints:

Vehicle.prototype.drive = function() { ... };
Vehicle.prototype.stop = function() { ... };
Vehicle.prototype.open = function() { ... };

Zum Vergleich hier selbiges Ergebnis über die klassenbasierte Vererbung:

class Vehicle {
  constructor() {
    this.power = "400PS";
    this.color = "Blue";
    this.speed = 320;
    this.tuned = false;
  }

  drive() {
    ...
  }

  stop() {
    ...
  }

  open() {
    ...
  }
}

Weitere Wege um Vererbung zu erreichen

Bisher haben wir gesehen, dass wir mit dem new-Keyword Objekten für die Vererbung erstellt haben. Hier nochmal ein Beispiel:

let car = new Vehicle();

Dies funktioniert unabhängig davon ob “Vehicle” als Klasse (klassenbasierte Vererbung) oder über eine Funktion (prototypische Vererbung) definiert wurde. Eine andere Möglichkeit ist die Nutzung der Object.create()-Methode. Dafür erstellen wir zunächst das Objekt:

const vehicle = {
  drive() {
    return "I am driving.";
  }
};

Und dann nutzen wir die genannte Methode, um Vererbung zu erreichen:

let car = Object.create(vehicle);

car.drive();

Eine weitere Methode ist die Nutzung der Object.setPrototypeOf-Methode. Hier ein Beispiel:

const vehicle = {
  drive() {
    return "I am driving.";
  }
};

let car = {};

Object.setPrototypeOf(car, vehicle);