Zum Inhalt springen

Virtuelle Funktionen, vtables & Polymorphie

Tauche ein in Laufzeitpolymorphie: wie virtueller Dispatch unter der Haube funktioniert, wann du override und final einsetzt, was abstrakte Klassen sind und wofür RTTI gut ist.

Laufzeitpolymorphie

Polymorphie bedeutet "Vielgestaltigkeit" — dieselbe Schnittstelle verhält sich je nach tatsächlichem Typ des Objekts unterschiedlich. In C++ erreicht man Laufzeitpolymorphie über virtuelle Funktionen. Rufst du eine virtuelle Funktion über einen Basiszeiger oder eine Basisreferenz auf, wird der Aufruf zur Laufzeit an die Implementierung der tatsächlichen abgeleiteten Klasse weitergeleitet.

Genau das macht OOP mächtig: Du kannst Code schreiben, der auf der Schnittstelle einer Basisklasse arbeitet, und er funktioniert automatisch korrekt mit jeder abgeleiteten Klasse — sogar mit solchen, die erst nach deinem Code entstanden sind.

Wie vtables funktionieren

Hat eine Klasse virtuelle Funktionen, legt der Compiler eine vtable (virtuelle Funktionstabelle) an — ein Array von Funktionszeigern. Jedes Objekt dieser Klasse enthält einen versteckten vptr (einen Zeiger auf die vtable). Bei einem virtuellen Aufruf folgt das Programm dem vptr zur vtable, schlägt dort den Funktionszeiger nach und ruft über ihn auf. Diese Indirektion ist der Grund, warum virtuelle Aufrufe gegenüber direkten Aufrufen einen kleinen Overhead haben.

polymorphism.cpp
#include <iostream>
#include <memory>
#include <vector>

class Shape {
public:
    virtual ~Shape() = default;

    // Pure virtual — no default implementation
    virtual double area() const = 0;

    // Virtual with default implementation
    virtual std::string name() const { return "Shape"; }
};

class Circle : public Shape {
    double radius_;
public:
    explicit Circle(double r) : radius_{r} {}

    double area() const override {
        return 3.14159265 * radius_ * radius_;
    }

    std::string name() const override { return "Circle"; }
};

class Rectangle : public Shape {
    double w_, h_;
public:
    Rectangle(double w, double h) : w_{w}, h_{h} {}

    double area() const override { return w_ * h_; }
    std::string name() const override { return "Rectangle"; }
};

// Works with ANY Shape — past, present, or future
void print_info(const Shape& s) {
    std::cout << s.name() << ": area = " << s.area() << '\n';
}

int main() {
    Circle c{5.0};
    Rectangle r{3.0, 4.0};

    print_info(c);  // Circle: area = 78.5398
    print_info(r);  // Rectangle: area = 12

    // Polymorphic container
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(2.0));
    shapes.push_back(std::make_unique<Rectangle>(6.0, 3.0));

    for (const auto& s : shapes) {
        print_info(*s);  // virtual dispatch at runtime
    }
    return 0;
}

Virtuelle Destruktoren sind Pflicht

Hat eine Klasse irgendeine virtuelle Funktion, muss ihr Destruktor virtuell sein. Andernfalls führt das Löschen eines abgeleiteten Objekts über einen Basiszeiger zu undefiniertem Verhalten — der abgeleitete Destruktor läuft nie und Ressourcen lecken.

virtual_destructor.cpp
#include <iostream>
#include <memory>

class Base {
public:
    // Without virtual destructor: UNDEFINED BEHAVIOR on delete
    // virtual ~Base() = default;  // ALWAYS do this!
    ~Base() { std::cout << "~Base\n"; }
};

class Derived : public Base {
    int* data_;
public:
    Derived() : data_{new int[1000]} {
        std::cout << "Derived: allocated\n";
    }
    ~Derived() {
        delete[] data_;
        std::cout << "~Derived: freed\n";
    }
};

int main() {
    Base* ptr = new Derived{};
    delete ptr;  // Only ~Base runs! ~Derived is NOT called!
    // Result: memory leak (data_ never freed)
    // Fix: make ~Base() virtual
    return 0;
}

Die Schlüsselwörter override und final

Das Schlüsselwort override teilt dem Compiler mit, dass du eine virtuelle Funktion der Basisklasse überschreiben willst. Passt die Signatur nicht (ein häufiger Tippfehler), meldet der Compiler das. Das Schlüsselwort final verhindert, dass eine Funktion weiter überschrieben wird, oder dass von einer Klasse geerbt wird.

override_final.cpp
#include <iostream>

class Base {
public:
    virtual ~Base() = default;
    virtual void process(int x) const { std::cout << "Base: " << x << '\n'; }
    virtual void execute() { std::cout << "Base::execute\n"; }
};

class Middle : public Base {
public:
    // override: compiler checks signature matches Base::process
    void process(int x) const override {
        std::cout << "Middle: " << x * 2 << '\n';
    }

    // final: no further derived class can override execute()
    void execute() final { std::cout << "Middle::execute\n"; }
};

class Bottom : public Middle {
public:
    void process(int x) const override {
        std::cout << "Bottom: " << x * 3 << '\n';
    }

    // ERROR if uncommented: execute() is final in Middle
    // void execute() override { }
};

// A final class cannot be inherited from at all
class Leaf final : public Base {
public:
    void process(int x) const override {
        std::cout << "Leaf: " << x << '\n';
    }
};

// ERROR if uncommented: cannot inherit from final class
// class Attempt : public Leaf {};

int main() {
    Bottom b;
    Base& ref = b;
    ref.process(10);  // Bottom: 30 (virtual dispatch)
    ref.execute();    // Middle::execute (final version)
    return 0;
}

Abstrakte Klassen & das NVI-Muster

Eine Klasse mit mindestens einer rein virtuellen Funktion (= 0) ist abstrakt — sie kann nicht instanziiert werden. Das Non-Virtual-Interface-Muster (NVI) stellt einen öffentlichen, nicht-virtuellen Einstiegspunkt bereit, der eine private virtuelle Funktion aufruft. So behält die Basisklasse die Kontrolle über Vor- und Nachbedingungen.

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

// NVI Pattern: public non-virtual calls private virtual
class Validator {
public:
    virtual ~Validator() = default;

    // Public non-virtual interface — controls the workflow
    bool validate(const std::string& input) const {
        if (input.empty()) {
            std::cout << "[Validator] Input is empty\n";
            return false;
        }
        // Delegate to derived class implementation
        bool result = do_validate(input);
        std::cout << "[Validator] " << (result ? "PASS" : "FAIL") << '\n';
        return result;
    }

private:
    // Pure virtual — derived classes MUST implement
    virtual bool do_validate(const std::string& input) const = 0;
};

class EmailValidator : public Validator {
    bool do_validate(const std::string& input) const override {
        return input.find('@') != std::string::npos
            && input.find('.') != std::string::npos;
    }
};

class LengthValidator : public Validator {
    std::size_t min_len_;
    bool do_validate(const std::string& input) const override {
        return input.length() >= min_len_;
    }
public:
    explicit LengthValidator(std::size_t min) : min_len_{min} {}
};

int main() {
    EmailValidator email_v;
    LengthValidator len_v{5};

    email_v.validate("user@example.com");  // PASS
    email_v.validate("invalid");            // FAIL
    len_v.validate("hi");                   // FAIL
    len_v.validate("hello!");               // PASS
    return 0;
}

RTTI & dynamic_cast

RTTI (Run-Time Type Information) erlaubt dir, den tatsächlichen Typ eines polymorphen Objekts zur Laufzeit abzufragen. dynamic_cast(base_ptr) wandelt einen Basiszeiger sicher in einen abgeleiteten Zeiger um und liefert nullptr, wenn die Umwandlung fehlschlägt. Bei Referenzen wirft ein fehlgeschlagener dynamic_cast ein std::bad_cast.

typeid(obj) liefert eine std::type_info-Referenz, die sich vergleichen lässt, um den Laufzeittyp zu bestimmen.

Setze RTTI sparsam ein — häufige dynamic_cast-Aufrufe deuten oft auf ein Designproblem hin. Bevorzuge virtuelle Funktionen für typspezifisches Verhalten.

Stolperfalle

Rufe niemals virtuelle Funktionen aus einem Konstruktor oder Destruktor auf. Während der Konstruktion ist der dynamische Typ des Objekts die gerade konstruierte Klasse, nicht die endgültige abgeleitete Klasse. Ein virtueller Aufruf in Base::Base() landet bei Base::method(), nicht bei Derived::method() — selbst wenn das erzeugte Objekt ein Derived ist. Das ist eine bekannte Fehlerquelle, die sich extrem schwer diagnostizieren lässt.

Wichtigste Erkenntnisse
  • Virtuelle Funktionen ermöglichen Laufzeitpolymorphie über vtables und vptrs
  • Jede Klasse mit virtuellen Funktionen muss einen virtual-Destruktor haben
  • Verwende beim Überschreiben immer override — es deckt abweichende Signaturen schon zur Kompilierzeit auf
  • Rein virtuelle Funktionen (= 0) machen eine Klasse abstrakt und zwingen abgeleitete Klassen zur Implementierung
  • Das NVI-Muster erlaubt Basisklassen, Vor- und Nachbedingungen rund um Anpassungspunkte durchzusetzen
  • Vermeide virtuelle Aufrufe in Konstruktoren und Destruktoren — die abgeleitete Klasse ist dort noch nicht (oder nicht mehr) vollständig konstruiert

Quiz — Teste dein Wissen

(20 XP)

1. Was passiert, wenn eine polymorphe Basisklasse KEINEN virtuellen Destruktor hat?

2. Was bewirkt das Schlüsselwort `override`?

3. Warum solltest du virtuelle Funktionen nicht aus einem Konstruktor aufrufen?