1. Warum dynamischer Polymorphismus gebraucht wird
Wenn Sie ein Programm schreiben, möchten Sie sich fast immer das Leben vereinfachen: ein einheitlicher Code, der „etwas tut“, ohne im ganzen Projekt Bedingungen wie „wenn das eine Katze ist — miaue, wenn das ein Hund ist — belle“ zu verstreuen. Ja, if/else ist nützlich, aber manchmal mutiert es zu einem Bedingungsmonster, das man ungern anfässt: neuen Typ hinzugefügt — und nun renne und ändere dutzende Stellen.
Dynamischer Polymorphismus in C++ löst genau dieses Problem: wir beschreiben ein gemeinsames Interface in der Basisklasse und erlauben den Nachkommen, das Verhalten zu überschreiben; der Methodenaufruf erfolgt „korrekt“, selbst wenn wir mit dem Objekt über den Basistyp (Referenz/Pointer) arbeiten. Im C++-Standard gibt es sogar den eigenen Begriff „virtual function call“, der betont, dass dies eine besondere Aufrufart ist, die die Implementierung zur Laufzeit auswählt.
Vererbung und der Sinn von is-a
Vererbung ist nicht primär „ein Weg, Code wiederzuverwenden“ (auch wenn es manchmal so aussieht), sondern vor allem ein Typenverhältnis. Wenn Sie schreiben class Cat : public Animal, behaupten Sie: „Eine Katze ist ein Tier“. Also muss jede Cat dort passen, wo ein Animal erwartet wird. Das ist wichtiger als die Syntax.
Die Syntax ist dabei recht ruhig: nach dem Klassennamen folgt ein Doppelpunkt und der Name des Basistyps. Das Schlüsselwort public bedeutet, dass das öffentliche Interface der Basisklasse beim Nachkommen öffentlich bleibt (für unsere Lehrzwecke ist das fast immer das Gewünschte).
#include <string>
struct Animal {
std::string name;
};
struct Cat : public Animal {
int lives = 9;
};
Hier hat Cat alles, was Animal hat, plus eigene Felder. Man kann sich vorstellen: innerhalb von Cat ist der Basisteil Animal „eingebettet“ (man spricht manchmal vom base subobject), daneben liegen die zusätzlichen Cat-Felder.
Kleine Schemata fürs Gehirn:
flowchart TB
C[Cat object] --> B[Base part: Animal]
C --> D[Derived part: Cat fields]
2. Normale und virtuelle Methoden: unterschiedliche Regeln
Ein häufiger Anfängerfehler sieht so aus: jemand erstellt eine Basisklasse, schreibt darin die Methode sound(), dann schreibt der Nachkomme eine Methode sound() mit demselben Namen — und erwartet, dass „sie automatisch überschrieben wird“. C++ sagt dagegen: „Ich habe nichts versprochen“.
Wenn eine Methode nicht virtual ist, dann wird bei einem Aufruf über Base&/Base* die Implementierung nach dem Typ der Referenz/des Pointers im Code gewählt, also nach dem statischen Typ. Und dieses Verhalten kann „ungerecht“ erscheinen, bis man sich an das Modell gewöhnt hat.
Vergleichen wir das an einem kurzen Beispiel.
Ohne virtual: kein Polymorphismus
Jetzt kommt ein Beispiel, das enttäuscht — aber sehr lehrreich ist.
#include <iostream>
#include <string>
struct Animal {
std::string sound() const { return "???"; } // NICHT virtual
};
struct Cat : public Animal {
std::string sound() const { return "meow"; }
};
void print_sound(const Animal& a) {
std::cout << a.sound() << '\n'; // ??? (und nicht meow)
}
int main() {
Cat c;
print_sound(c);
}
Warum wird "???" ausgegeben? Weil print_sound eine const Animal& nimmt, und der Compiler die Implementierung von Animal::sound() „wie geschrieben“ wählt, da die Methode nicht virtual ist.
Mit virtual: dynamische Auswahl der Implementierung
Fügen wir jetzt virtual in die Basisklasse ein — und plötzlich verhält sich alles so, wie wir es intuitiv erwarten.
#include <iostream>
#include <string>
struct Animal {
virtual std::string sound() const { return "???"; }
};
struct Cat : public Animal {
std::string sound() const { return "meow"; }
};
void print_sound(const Animal& a) {
std::cout << a.sound() << '\n'; // meow
}
int main() {
Cat c;
print_sound(c);
}
Jetzt folgt der Aufruf a.sound() den Regeln für virtuelle Funktionen: die Implementierung wird nach dem realen Typ des Objekts ausgewählt (also danach, was das Objekt zur Laufzeit tatsächlich ist). Das ist dynamischer Polymorphismus.
4. override: Schutz vor „fast überschrieben“
Wenn Sie anfangen, virtuelle Methoden zu schreiben, erscheint eine neue Fehlerklasse: „Ich dachte, ich habe die Methode überschrieben, aber in Wirklichkeit habe ich eine andere geschrieben“. Die Ursachen sind meist klein und ärgerlich: const vergessen, Referenz/Wert vertauscht, irgendwo ein & hinzugefügt oder entfernt.
Dafür gibt es in C++ das Schlüsselwort override. Es wird im Nachkommen geschrieben und bedeutet: „Compiler, prüfe bitte, dass ich tatsächlich eine virtuelle Methode der Basisklasse überschreibe“.
Sehr nützliche Disziplin: wenn du überschreibst — schreibe immer override. Das ist kein bloßer Style, sondern ein Sicherheitsgurt gegen Bugs, die sonst schwer zu finden sind.
Signaturfehler: override rettet
struct Base {
virtual int value() const { return 1; }
};
struct Derived : public Base {
// int value() override { return 2; } // Compilerfehler: kein const
int value() const override { return 2; }
};
Hier zwingt override den Compiler zu sagen: „Stopp, du wolltest überschreiben, aber die Signatur stimmt nicht überein“. Ohne override könnte der Code kompiliert werden und Sie hätten zwei verschiedene Methoden, sodass der Polymorphismus nicht wie erwartet wirkt.
Was gehört zur Signaturgleichheit
| Was übereinstimmen muss | Beispiel für eine „Kleinigkeit“, die override bricht |
|---|---|
| Parameterliste | vs |
beim Method |
vs |
| Referenzcharakter der Parameter | vs |
/ Qualifier (wir gehen hier nicht ins Detail) |
selten bei Anfängern, beeinflusst aber ebenfalls die Signatur |
5. Statischer und realer Typ: wie die Methode gewählt wird
An diesem Punkt haben Anfänger oft das Gefühl: „Okay, virtual ist irgendwie Magie“. Um die Magie zu entmystifizieren, merken Sie sich zwei Typen:
Statischer Typ — das, was im Code steht: Typ der Variable, Typ des Parameters, Typ des Pointers/der Referenz. Er ist dem Compiler bekannt.
Realer (dynamischer) Typ — das, was das Objekt zur Laufzeit tatsächlich ist.
Beispiel: wenn Sie Cat c; haben, dann sind statischer und realer Typ beide Cat. Wenn Sie jedoch machen Animal& a = c;, ist der statische Typ von a Animal&, aber der reale Typ des Objekts, auf das es verweist, ist Cat.
Schemata zur Festigung:
sequenceDiagram
participant Code as Code (statischer Typ)
participant Obj as Objekt (realer Typ)
Code->>Obj: Animal& a = c (c ist Cat)
Note over Code: a hat Typ Animal&
Note over Obj: das Objekt ist immer noch Cat
Code->>Obj: a.sound()
Note over Obj: bei virtual wählen wir Cat::sound()
Aus diesem Unterschied ergibt sich, warum virtual sinnvoll ist: es sagt dem Compiler, „erlaube die Auswahl der Implementierung nach dem realen Laufzeittyp, nicht nach dem Typ der Referenz/des Pointers“.
6. Virtueller Aufruf über Base& und Base*
Wichtig zu merken: Virtualität ist besonders nützlich genau dann, wenn Sie auf das Objekt über den Basistyp zugreifen. Wenn Sie die Methode direkt an Cat c; mit c.sound(); aufrufen, dann ist ohne Polymorphismus alles klar: die Cat-Methode wird aufgerufen.
Dynamischer Polymorphismus zeigt sich in Situationen, wo Sie einen Parameter vom Typ Base&/Base* empfangen, einen Pointer auf die Basisklasse speichern oder eine Basisreferenz/einen Basispointer aus einer Funktion zurückgeben.
Aufruf über Referenz
#include <iostream>
#include <string>
struct Animal {
virtual std::string sound() const { return "???"; }
};
struct Dog : public Animal {
std::string sound() const override { return "woof"; }
};
void print_sound(const Animal& a) {
std::cout << a.sound() << '\n'; // woof
}
int main() {
Dog d;
print_sound(d);
}
Aufruf über Pointer
#include <iostream>
#include <string>
struct Animal {
virtual std::string sound() const { return "???"; }
};
struct Cat : public Animal {
std::string sound() const override { return "meow"; }
};
int main() {
Cat c;
Animal* p = &c;
std::cout << p->sound() << '\n'; // meow
}
Beachten Sie p->sound(): das ist genau der Zugriff über Animal*, also über den Basistyp, und deshalb entscheidet die Virtualität, welche Implementierung aufgerufen wird.
7. Praktisches Beispiel: Aktionen in einer Konsolenanwendung
Damit das Thema nicht bei Katzen und Hunden bleibt, fügen wir Polymorphismus in einen praxisnahen Kontext ein. Stellen Sie sich vor, bisher haben Sie eine einfache Konsolenanwendung (ein hypothetischer „TaskTracker“) mit einem Zustandsobjekt App und mehreren Aktionen: Aufgabe hinzufügen, Liste ausgeben, Hilfe anzeigen. Üblich ist, dass Anfänger eine riesige if/else-Kette über die Kommandozeile bauen.
Wir machen jetzt einen Schritt zu einer saubereren Architektur: wir beschreiben eine Basisklasse Action und einige Nachkommen. Wir bauen keine komplexen Sammlungen polymorpher Objekte (das ist ein eigenes großes Thema), sondern zeigen lediglich den Mechanismus des virtuellen Aufrufs.
Basisklasse der Aktion
Zuerst erstellen wir die Basiseinheit „Aktion“. Sie kann einen Namen zurückgeben (für die Ausgabe) und sich selbst ausführen.
#include <string>
struct App {
int tasks = 0; // hypothetisch: Anzahl der Aufgaben
};
struct Action {
virtual std::string title() const { return "unknown"; }
virtual void run(App& app) const { (void)app; }
};
Beide Methoden sind hier virtual, damit die Nachkommen das Verhalten ändern können.
Aktion „Aufgabe hinzufügen“
#include <string>
struct AddTaskAction : public Action {
std::string title() const override { return "add"; }
void run(App& app) const override {
++app.tasks;
}
};
Aktion „Statistiken anzeigen“
#include <iostream>
#include <string>
struct StatsAction : public Action {
std::string title() const override { return "stats"; }
void run(App& app) const override {
std::cout << "Tasks: " << app.tasks << '\n'; // Tasks: 0 (oder mehr)
}
};
Funktion, die über den Basistyp arbeitet
Hier der Kernpunkt: die Funktion weiß nicht, welche konkrete Aktion ihr übergeben wurde. Sie sieht nur Action.
#include <iostream>
void execute(const Action& action, App& app) {
std::cout << "Executing: " << action.title() << '\n';
action.run(app);
}
Und nun ein Mini-Szenario:
int main() {
App app;
AddTaskAction add;
StatsAction stats;
execute(add, app);
execute(stats, app);
}
Genau hier passiert das, wofür das Ganze gedacht war: execute nimmt eine const Action&, ruft aber abhängig vom realen Typ des übergebenen Objekts unterschiedliche Implementierungen von title() und run() auf.
Beachten Sie eine wichtige (leicht tückische) Einzelheit: wir haben add und stats per Referenz übergeben. Das bewahrt die Verbindung zum realen Objekt und erlaubt dem virtuellen Aufruf zu funktionieren. In späteren Vorlesungen besprechen wir ausführlich, welche Arten der Übergabe/Despeicherung Erwartungen an Polymorphismus brechen können, aber merken Sie sich vorerst: polymorpher Gebrauch bedeutet meist Arbeit über Base& oder Base*.
8. Typische Fehler bei der Arbeit mit virtual/override
Fehler Nr. 1: in der Basis wurde vergessen, die Methode virtual zu machen, trotzdem erwartet man eine „Verhaltenssubstitution“.
Solcher Code kompiliert und sieht logisch aus, ruft aber die Basisversion der Methode bei Zugriff über Base&/Base* auf. Deshalb können Sie lange auf das Programm starren und denken „aber ich habe doch Cat::sound() geschrieben“. Die Lösung ist einfach: wenn Sie dynamische Auswahl erwarten — muss die Methode in der Basisklasse virtual sein.
Fehler Nr. 2: das Überschreiben „stimmte fast“, aber die Signatur ist anders und es entstand eine neue Methode.
Klassisches Muster: const vergessen, std::string und const std::string& vertauscht, oder Parameter-Typ geändert. Als Ergebnis haben Sie die Methode nicht überschrieben, sondern eine neue hinzugefügt. Genau deshalb ist override kein Zierat, sondern täglicher Sicherheitsgurt: der Compiler beschwert sich sofort.
Fehler Nr. 3: man erwartet Polymorphismus, wo gar kein Zugriff über den Basistyp stattfindet.
Wenn Sie Cat c; erstellen und c.sound() aufrufen, dann rufen Sie sowieso die Cat-Methode — hier ändert virtual nichts Grundsätzliches. Polymorphismus wirkt dann, wenn Sie das Objekt bewusst „als Base betrachten“, also via Base& oder Base*.
Fehler Nr. 4: Vererbung wird genutzt „wegen gemeinsamer Implementierung“, obwohl es kein is-a ist.
Manchmal möchte man class Logger : public std::string oder class Button : public Vector zu erben, weil „so Funktionalität einfacher nutzbar ist“. Das endet meist in merkwürdigem Design und unerwarteten Einschränkungen: der Typ verspricht der Außenwelt Dinge, die er nicht wirklich ist. Gute Gewohnheit: bevor Sie : public Base schreiben, sprechen Sie laut: „Derived ist Base“. Klingt das seltsam — dann lieber stoppen und nachdenken.
Fehler Nr. 5: es wird nicht klar formuliert, welche Methode Erweiterungspunkt ist.
Wenn in der Basisklasse zu viele virtual-Methoden sind, werden Nachkommen zu „Geiseln“ der Basis, und jede Interface-Änderung wird zu einem Projektmini-Desaster. Sind zu wenige virtual-Methoden vorhanden — landet man wieder bei if/else. Ein guter Ingenieursausgleich entsteht, wenn Sie bewusst entscheiden: „dieses Verhalten wird variieren — also ist die Methode virtual“, und alles andere bleibt normal.
GO TO FULL VERSION