15 Klassen und Vererbung (ES6+)

Mit ES6 (ECMAScript 2015) wurde die Klassensyntax in JavaScript eingeführt. Diese Syntax vereinfacht die Implementierung objektorientierter Konzepte erheblich, ohne jedoch die grundlegende prototypenbasierte Natur von JavaScript zu verändern. Klassen in JavaScript sind im Wesentlichen “syntaktischer Zucker” über dem bestehenden Prototypen-Mechanismus, den wir im vorherigen Kapitel betrachtet haben.

15.1 Klassendefinition und -instanziierung

Eine Klasse in JavaScript wird mit dem Schlüsselwort class definiert und kann einen Konstruktor, Methoden und Getter/Setter enthalten:

class Fahrzeug {
  constructor(marke, modell, baujahr) {
    this.marke = marke;
    this.modell = modell;
    this.baujahr = baujahr;
    this._kilometerstand = 0;
  }
  
  // Methode
  fahren(kilometer) {
    this._kilometerstand += kilometer;
    return `${this.marke} ${this.modell} fährt ${kilometer} km`;
  }
  
  // Getter
  get kilometerstand() {
    return this._kilometerstand;
  }
  
  // Setter
  set kilometerstand(wert) {
    if (wert >= 0) {
      this._kilometerstand = wert;
    } else {
      throw new Error('Kilometerstand kann nicht negativ sein');
    }
  }
  
  // Statische Methode
  static istFahrzeug(obj) {
    return obj instanceof Fahrzeug;
  }
}

// Instanziierung
const meinAuto = new Fahrzeug('VW', 'Golf', 2020);
console.log(meinAuto.fahren(100)); // "VW Golf fährt 100 km"
console.log(meinAuto.kilometerstand); // 100

// Statische Methode aufrufen
console.log(Fahrzeug.istFahrzeug(meinAuto)); // true

15.2 Unter der Haube: Klassen und Prototypen

Die obige Klassendefinition erzeugt im Wesentlichen dasselbe Ergebnis wie die folgende Verwendung des Prototypen-Mechanismus:

function Fahrzeug(marke, modell, baujahr) {
  this.marke = marke;
  this.modell = modell;
  this.baujahr = baujahr;
  this._kilometerstand = 0;
}

Fahrzeug.prototype.fahren = function(kilometer) {
  this._kilometerstand += kilometer;
  return `${this.marke} ${this.modell} fährt ${kilometer} km`;
};

Object.defineProperty(Fahrzeug.prototype, 'kilometerstand', {
  get: function() {
    return this._kilometerstand;
  },
  set: function(wert) {
    if (wert >= 0) {
      this._kilometerstand = wert;
    } else {
      throw new Error('Kilometerstand kann nicht negativ sein');
    }
  }
});

Fahrzeug.istFahrzeug = function(obj) {
  return obj instanceof Fahrzeug;
};

15.3 Vererbung mit extends

Eine der größten Stärken der ES6-Klassensyntax ist die vereinfachte Vererbung mit dem Schlüsselwort extends:

class Auto extends Fahrzeug {
  constructor(marke, modell, baujahr, türen) {
    // Ruft den Konstruktor der Elternklasse auf
    super(marke, modell, baujahr);
    this.türen = türen;
    this.typ = 'Auto';
  }
  
  // Überschreibt die Methode der Elternklasse
  fahren(kilometer) {
    const nachricht = super.fahren(kilometer); // Elternmethode aufrufen
    return `${nachricht} als ${this.typ}`;
  }
  
  // Zusätzliche Methode
  parken() {
    return `${this.marke} ${this.modell} parkt`;
  }
}

const meinAuto = new Auto('BMW', '3er', 2021, 4);
console.log(meinAuto.fahren(200)); // "BMW 3er fährt 200 km als Auto"
console.log(meinAuto.parken()); // "BMW 3er parkt"
console.log(meinAuto.kilometerstand); // 200 (geerbt)

15.4 Das super-Schlüsselwort

super hat in JavaScript-Klassen zwei wichtige Verwendungen:

  1. Als Funktionsaufruf (super()) im Konstruktor: Ruft den Konstruktor der Elternklasse auf
  2. Als Objektreferenz (super.methode()) in Methoden: Greift auf Methoden der Elternklasse zu
class ElektroAuto extends Auto {
  constructor(marke, modell, baujahr, türen, batteriekapazität) {
    super(marke, modell, baujahr, türen); // Muss vor this aufgerufen werden!
    this.batteriekapazität = batteriekapazität;
    this.typ = 'Elektroauto';
  }
  
  laden() {
    return `${this.marke} ${this.modell} lädt seine ${this.batteriekapazität}kWh-Batterie`;
  }
}

const teslaModel3 = new ElektroAuto('Tesla', 'Model 3', 2022, 4, 75);
console.log(teslaModel3.laden()); // "Tesla Model 3 lädt seine 75kWh-Batterie"
console.log(teslaModel3.fahren(150)); // "Tesla Model 3 fährt 150 km als Elektroauto"

15.5 Wichtige Besonderheiten von Klassen

  1. Klassen sind keine Hoisting-Objekte: Im Gegensatz zu Funktionen werden Klassen nicht “gehoisted”, d.h. sie müssen definiert werden, bevor sie verwendet werden können.

  2. Klassenrumpf wird im strict mode ausgeführt: Der Code innerhalb einer Klassendeklaration unterliegt automatisch dem “strict mode”.

  3. Klassenmethoden sind nicht aufzählbar: Methoden, die mit der Klassensyntax erstellt wurden, haben das Flag enumerable: false.

  4. Konstruktor-Aufruf ohne new: Konstruktoren von Klassen müssen mit dem new-Operator aufgerufen werden.

// Fehler: Klasse vor Definition verwenden
try {
  const k = new KlasseNochNichtDefiniert();
} catch (e) {
  console.log("Fehler: " + e.message);
}

class KlasseNochNichtDefiniert {}

// Fehler: Konstruktor ohne new
try {
  Fahrzeug("Mercedes", "C-Klasse", 2019);
} catch (e) {
  console.log("Fehler: " + e.message); // TypeError: Class constructor Fahrzeug cannot be invoked without 'new'
}

15.6 Private Felder und Methoden

Ab ECMAScript 2022 können private Klassenelemente mit dem Präfix # definiert werden:

class BankKonto {
  #kontostand = 0;  // Privates Feld
  #pin;             // Privates Feld ohne Initialisierung
  
  constructor(kontoinhaber, startguthaben, pin) {
    this.kontoinhaber = kontoinhaber;  // Öffentliches Feld
    this.#kontostand = startguthaben;
    this.#pin = pin;
  }
  
  // Private Methode
  #prüfePin(pin) {
    return pin === this.#pin;
  }
  
  abheben(betrag, pin) {
    if (!this.#prüfePin(pin)) {
      throw new Error('Falsche PIN');
    }
    
    if (betrag > this.#kontostand) {
      throw new Error('Nicht genügend Guthaben');
    }
    
    this.#kontostand -= betrag;
    return `${betrag}€ abgehoben. Neuer Kontostand: ${this.#kontostand}€`;
  }
  
  einzahlen(betrag) {
    this.#kontostand += betrag;
    return `${betrag}€ eingezahlt. Neuer Kontostand: ${this.#kontostand}€`;
  }
  
  get kontostand() {
    return `Aktueller Kontostand: ${this.#kontostand}€`;
  }
}

const meinKonto = new BankKonto('Max Mustermann', 1000, 1234);
console.log(meinKonto.einzahlen(500)); // "500€ eingezahlt. Neuer Kontostand: 1500€"
console.log(meinKonto.kontostand); // "Aktueller Kontostand: 1500€"

try {
  console.log(meinKonto.#kontostand); // SyntaxError: Private field '#kontostand' cannot be accessed
} catch (e) {
  console.log("Zugriffsfehler auf privates Feld");
}

Private Felder:

15.7 Statische Initialisierungsblöcke

Ab ECMAScript 2022 können statische Initialisierungsblöcke verwendet werden, um komplexe statische Initialisierungen durchzuführen:

class Konfiguration {
  static defaultConfig;
  static validSettings;
  
  static {
    // Komplexe Initialisierung für statische Eigenschaften
    this.defaultConfig = {
      darkMode: true,
      fontSize: 16,
      language: 'de'
    };
    
    this.validSettings = new Set(['darkMode', 'fontSize', 'language']);
    
    // Weitere Initialisierungslogik...
    console.log('Statische Initialisierung abgeschlossen');
  }
  
  static isValidSetting(key) {
    return this.validSettings.has(key);
  }
}

console.log(Konfiguration.defaultConfig.darkMode); // true
console.log(Konfiguration.isValidSetting('fontSize')); // true

15.8 Klassenausdrücke

Ähnlich wie bei Funktionsausdrücken können Klassen auch als Ausdrücke definiert werden:

// Anonymer Klassenausdruck
const Person = class {
  constructor(name) {
    this.name = name;
  }
  
  grüßen() {
    return `Hallo, ich bin ${this.name}`;
  }
};

// Benannter Klassenausdruck
const Mitarbeiter = class MitarbeiterKlasse {
  constructor(name, abteilung) {
    this.name = name;
    this.abteilung = abteilung;
  }
  
  vorstellen() {
    return `${this.name} aus der Abteilung ${this.abteilung}`;
  }
  
  // Der Name MitarbeiterKlasse ist nur innerhalb der Klasse sichtbar
  getClassName() {
    return MitarbeiterKlasse.name;
  }
};

const max = new Person('Max');
console.log(max.grüßen()); // "Hallo, ich bin Max"

const anna = new Mitarbeiter('Anna', 'IT');
console.log(anna.vorstellen()); // "Anna aus der Abteilung IT"
console.log(anna.getClassName()); // "MitarbeiterKlasse"

15.9 Getter und Setter

Getter und Setter erlauben es, Eigenschaftszugriffe zu kontrollieren:

class Temperatur {
  constructor(celsius) {
    this._celsius = celsius;
  }
  
  get celsius() {
    return this._celsius;
  }
  
  set celsius(wert) {
    if (wert < -273.15) {
      throw new Error('Temperatur kann nicht unter dem absoluten Nullpunkt liegen');
    }
    this._celsius = wert;
  }
  
  get fahrenheit() {
    return this._celsius * 9/5 + 32;
  }
  
  set fahrenheit(wert) {
    this.celsius = (wert - 32) * 5/9;
  }
  
  get kelvin() {
    return this._celsius + 273.15;
  }
  
  set kelvin(wert) {
    this.celsius = wert - 273.15;
  }
}

const temp = new Temperatur(25);
console.log(temp.fahrenheit); // 77
temp.fahrenheit = 86;
console.log(temp.celsius); // 30
console.log(temp.kelvin); // 303.15

15.10 Vererbungshierarchien und Polymorphismus

Die Vererbung in JavaScript ermöglicht das Erstellen komplexer Hierarchien und polymorphisches Verhalten:

// Basisklasse
class Shape {
  constructor(color) {
    this.color = color;
  }
  
  draw() {
    return `Zeichne eine Form in ${this.color}`;
  }
  
  area() {
    throw new Error('Muss in Unterklasse implementiert werden');
  }
}

// Unterklassen
class Circle extends Shape {
  constructor(color, radius) {
    super(color);
    this.radius = radius;
  }
  
  draw() {
    return `${super.draw()} als Kreis mit Radius ${this.radius}`;
  }
  
  area() {
    return Math.PI * this.radius * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(color, width, height) {
    super(color);
    this.width = width;
    this.height = height;
  }
  
  draw() {
    return `${super.draw()} als Rechteck ${this.width}x${this.height}`;
  }
  
  area() {
    return this.width * this.height;
  }
}

// Polymorphismus demonstrieren
function renderShape(shape) {
  console.log(shape.draw());
  console.log(`Fläche: ${shape.area().toFixed(2)}`);
}

const circle = new Circle('rot', 5);
const rectangle = new Rectangle('blau', 4, 6);

renderShape(circle);     // "Zeichne eine Form in rot als Kreis mit Radius 5"
                         // "Fläche: 78.54"
renderShape(rectangle);  // "Zeichne eine Form in blau als Rechteck 4x6"
                         // "Fläche: 24.00"

15.11 Mixin-Muster mit Klassen

JavaScript unterstützt keine Mehrfachvererbung, aber mit Mixins können Methoden von mehreren Quellen kombiniert werden:

// Mixin-Funktion
const SwimmableMixin = (superclass) => class extends superclass {
  swim() {
    return `${this.name || 'Objekt'} schwimmt`;
  }
  
  dive() {
    return `${this.name || 'Objekt'} taucht`;
  }
};

const FlyableMixin = (superclass) => class extends superclass {
  fly() {
    return `${this.name || 'Objekt'} fliegt`;
  }
  
  land() {
    return `${this.name || 'Objekt'} landet`;
  }
};

// Basisklasse
class Animal {
  constructor(name) {
    this.name = name;
  }
  
  eat() {
    return `${this.name} frisst`;
  }
}

// Klassen mit Mixins
class Duck extends SwimmableMixin(FlyableMixin(Animal)) {
  quack() {
    return `${this.name} quakt`;
  }
}

class Fish extends SwimmableMixin(Animal) {
  // Keine weiteren Methoden nötig
}

const donald = new Duck('Donald');
console.log(donald.eat());  // "Donald frisst"
console.log(donald.swim()); // "Donald schwimmt"
console.log(donald.fly());  // "Donald fliegt"
console.log(donald.quack()); // "Donald quakt"

const nemo = new Fish('Nemo');
console.log(nemo.eat());  // "Nemo frisst"
console.log(nemo.swim()); // "Nemo schwimmt"
try {
  console.log(nemo.fly()); // TypeError: nemo.fly is not a function
} catch (e) {
  console.log("Fische können nicht fliegen");
}