Zum Inhalt springen

Vererbung & Ist-ein-Beziehungen

Lerne öffentliche Vererbung, Konstruktorketten, das Slicing-Problem, Mehrfachvererbung und das Diamantproblem kennen — und wann Vererbung überhaupt angebracht ist.

Vererbung modelliert Ist-ein

Öffentliche Vererbung modelliert eine "ist-ein"-Beziehung: Ein Dog ist ein Animal, ein SavingsAccount ist ein BankAccount. Geregelt wird das vom Liskovschen Substitutionsprinzip (LSP): Überall dort, wo eine Basisklasse erwartet wird, muss eine abgeleitete Klasse ohne Überraschungen korrekt funktionieren.

Vererbung ist eines der mächtigsten — und am häufigsten missbrauchten — Features der OOP. Nicht jede Beziehung ist ein "ist-ein". Ein Stack ist kein Vector, auch wenn er intern einen verwendet. Vererbung dort einzusetzen, wo Komposition angebracht wäre, führt zu fragilem, schwer wartbarem Code.

Grundlegende Vererbung & Konstruktorketten

Eine abgeleitete Klasse erbt alle Member der Basisklasse. Der Konstruktor der Basisklasse läuft zuerst, danach der Konstruktor der abgeleiteten Klasse. Die Zerstörung erfolgt in umgekehrter Reihenfolge.

inheritance_basic.cpp
#include <iostream>
#include <string>

class Animal {
    std::string name_;
    int age_;

public:
    Animal(const std::string& name, int age)
        : name_{name}, age_{age}
    {
        std::cout << "Animal constructed: " << name_ << '\n';
    }

    virtual ~Animal() {
        std::cout << "Animal destroyed: " << name_ << '\n';
    }

    const std::string& name() const { return name_; }
    int age() const { return age_; }

    virtual void speak() const {
        std::cout << name_ << " makes a sound\n";
    }
};

class Dog : public Animal {  // public inheritance = is-a
    std::string breed_;

public:
    Dog(const std::string& name, int age, const std::string& breed)
        : Animal{name, age}  // base class constructor MUST be called first
        , breed_{breed}
    {
        std::cout << "Dog constructed: " << name() << '\n';
    }

    ~Dog() override {
        std::cout << "Dog destroyed: " << name() << '\n';
    }

    void speak() const override {
        std::cout << name() << " barks! (" << breed_ << ")\n";
    }
};

int main() {
    Dog rex{"Rex", 5, "German Shepherd"};
    rex.speak();  // Rex barks! (German Shepherd)
    // Destruction order: Dog destroyed, then Animal destroyed
    return 0;
}

Das Slicing-Problem

Object Slicing tritt auf, wenn ein abgeleitetes Objekt per Wert an eine Variable vom Typ der Basisklasse zugewiesen oder übergeben wird. Der abgeleitete Teil wird dabei "abgeschnitten" — übrig bleibt nur der Basisanteil. Das ist ein subtiler und häufiger Fehler.

slicing.cpp
#include <iostream>
#include <string>
#include <vector>
#include <memory>

class Shape {
public:
    virtual ~Shape() = default;
    virtual std::string type() const { return "Shape"; }
};

class Circle : public Shape {
    double radius_;
public:
    explicit Circle(double r) : radius_{r} {}
    std::string type() const override { return "Circle"; }
};

// BUG: passing by value slices the object!
void print_type_bad(Shape s) {
    std::cout << s.type() << '\n';  // always prints "Shape"!
}

// CORRECT: pass by reference to preserve polymorphism
void print_type_good(const Shape& s) {
    std::cout << s.type() << '\n';  // prints actual type
}

int main() {
    Circle c{5.0};

    print_type_bad(c);   // "Shape"  — SLICED!
    print_type_good(c);  // "Circle" — correct

    // Slicing also happens with containers of values:
    // std::vector<Shape> shapes;  // BAD: slicing on insert
    // Use pointers instead:
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(3.0));
    std::cout << shapes[0]->type() << '\n';  // "Circle"

    return 0;
}

Mehrfachvererbung & das Diamantproblem

C++ erlaubt einer Klasse, von mehreren Basisklassen zu erben. Das Diamantproblem entsteht, wenn zwei Basisklassen einen gemeinsamen Vorfahren haben und die abgeleitete Klasse dadurch zwei Kopien der Daten dieses Vorfahren enthält. Virtuelle Vererbung löst das.

diamond.cpp
#include <iostream>

// Diamond problem:
//       Device
//      /      \
//  Printer  Scanner
//      \      /
//     AllInOne

class Device {
protected:
    int id_;
public:
    Device(int id) : id_{id} {
        std::cout << "Device(" << id_ << ")\n";
    }
    virtual ~Device() = default;
};

// Virtual inheritance: only ONE copy of Device in AllInOne
class Printer : virtual public Device {
public:
    Printer(int id) : Device{id} {
        std::cout << "Printer\n";
    }
    void print_doc() { std::cout << "Printing on device " << id_ << '\n'; }
};

class Scanner : virtual public Device {
public:
    Scanner(int id) : Device{id} {
        std::cout << "Scanner\n";
    }
    void scan_doc() { std::cout << "Scanning on device " << id_ << '\n'; }
};

class AllInOne : public Printer, public Scanner {
public:
    // With virtual inheritance, the most-derived class
    // must initialize the virtual base directly
    AllInOne(int id) : Device{id}, Printer{id}, Scanner{id} {
        std::cout << "AllInOne\n";
    }
};

int main() {
    AllInOne aio{42};
    aio.print_doc();  // Printing on device 42
    aio.scan_doc();   // Scanning on device 42
    // Only ONE Device sub-object exists — no ambiguity
    return 0;
}

Kovariante Rückgabetypen & protected-Zugriff

Kovariante Rückgabetypen erlauben einer abgeleiteten Klasse, eine virtuelle Funktion zu überschreiben und dabei einen spezifischeren (abgeleiteten) Zeiger- oder Referenztyp zurückzugeben. Gibt Base::clone() zum Beispiel Base* zurück, darf Derived::clone() ein Derived* zurückgeben.

Der Zugriffsspezifizierer protected liegt zwischen public und private: Auf protected-Member können die Klasse selbst und ihre abgeleiteten Klassen zugreifen, Code von außen aber nicht. Verwende protected für Member, die abgeleitete Klassen tatsächlich brauchen — aber sei vorsichtig, denn es erzeugt eine Kopplung zwischen Basisklasse und abgeleiteter Klasse.

Stolperfalle

Ein häufiger Fehler ist, Vererbung nur einzusetzen, um Code wiederzuverwenden. Erbt ein Stack von std::vector, können Nutzer push_back, insert und andere vector-Operationen aufrufen, die die Stack-Abstraktion verletzen. Vererbung sollte eine echte Ist-ein-Beziehung modellieren, die dem Liskovschen Substitutionsprinzip genügt. Für Code-Wiederverwendung ist Komposition die bessere Wahl (ein Stack, der einen vector *enthält*).

Best Practice

Verwende immer public-Vererbung — private oder protected Vererbung wird selten gebraucht und stiftet nur Verwirrung. Deklariere Destruktoren von Basisklassen immer als virtual. Übergib polymorphe Objekte per Referenz oder Zeiger, niemals per Wert (um Slicing zu vermeiden). Bevorzuge Komposition gegenüber Vererbung, sofern die Beziehung nicht wirklich ein "ist-ein" ist. Vermeide tiefe Vererbungshierarchien (2-3 Ebenen sind in der Praxis meist die Obergrenze).

Wichtigste Erkenntnisse
  • Öffentliche Vererbung modelliert ist-ein — die abgeleitete Klasse muss dem Liskovschen Substitutionsprinzip genügen
  • Basiskonstruktoren laufen zuerst, Destruktoren zuletzt (umgekehrte Reihenfolge der Konstruktion)
  • Object Slicing tritt auf, wenn ein abgeleitetes Objekt per Wert an eine Basisklasse übergeben wird — verwende immer Referenzen oder Zeiger
  • Das Diamantproblem löst man mit virtual-Vererbung, besser aber mit Komposition
  • Vererbung dient der Ersetzbarkeit, nicht der Code-Wiederverwendung — nimm für Letztere Komposition

Quiz — Teste dein Wissen

(15 XP)

1. Was ist Object Slicing?

2. Wie löst virtuelle Vererbung das Diamantproblem?

3. Warum solltest du für eine `Stack`-Klasse NICHT von `std::vector` erben?