6
Vererbung

Dieses Kapitel behandelt die folgenden Themen:

Image       Vererbung und Objektorientierung

Image       Beziehung zwischen Ober- und Unterklasse

Image       Polymorphismus und seine Vorteile

Image       Mehrfachvererbung

Image       Typumwandlung bei Vererbung

Image       Typ eines Objekts zur Laufzeit eines Programms ermitteln

Vererbung ist ein Konzept, Eigenschaften, die mehreren Klassen gemeinsam sind, zu beschreiben. Damit wird eine diesen Klassen gemeinsame Schnittstelle spezifiziert. Vererbung und der damit zusammenhängende Begriff Polymorphismus werden dargestellt und an Beispielen erläutert. Eine Klasse kann viele Väter oder Mütter haben – der Abschnitt »Mehrfachvererbung« geht darauf ein. Der Vererbungsmechanismus zeichnet sich durch folgende Punkte aus:

Image       Eine Menge von ähnlichen Dingen wird mit einer Klasse beschrieben. Eigenschaften, die mehreren Klassen gemeinsam sind, können als verallgemeinertes Konzept betrachtet werden, das besonders behandelt wird. Es wird ebenfalls als Klasse beschrieben und Oberklasse genannt.

Image       Eine Klasse benutzt die Schnittstellen der Oberklasse und definiert Unterschiede zur Oberklasse. Mit anderen Worten: Sie ist eine Spezialisierung der Oberklasse.

Image       Die Vererbung ist hierarchisch organisiert, d.h., eine Oberklasse kann selbst wieder eine Oberklasse haben.

Ein Beispiel: Alle Autos haben etwas gemeinsam. Diese Gemeinsamkeiten kann man als Klasse Auto beschreiben. Auch Fahrräder haben etwas gemeinsam, formulierbar als Klasse Fahrrad. Sowohl Autos als auch Fahrräder haben Räder. Das heißt, auch verschiedene Klassen können Gemeinsamkeiten haben. Zum Beispiel sind Autos und Fahrräder Fahrzeuge. Eine Klasse Fahrzeug kann die Gemeinsamkeiten beschreiben. Die vererbende Klasse heißt Oberklasse oder Basisklasse (hier Fahrzeug), die erbende Klasse heißt Unterklasse oder abgeleitete Klasse (hier Auto bzw. Fahrrad). In der Literatur werden die Begriffe nicht einheitlich gebraucht. Im Falle von nur einer abgeleiteten Klasse ist die Oberklasse gleichzeitig die Basisklasse. Die Vererbung beschreibt eine Ist-ein-Beziehung. Ein Fahrrad ist ein Fahrzeug, ebenso wie ein Auto ein Fahrzeug ist. Die Vererbung ist eine gerichtete Beziehung, weil die Umkehrung im Allgemeinen nicht gilt: Ein Fahrzeug ist nicht unbedingt ein Fahrrad.

Wie eine Klasse die Abstraktion von ähnlichen Eigenschaften und Verhaltensweisen ähnlicher Objekte ist, ist eine Oberklasse die Abstraktion oder Generalisierung von ähnlichen Eigenschaften und Verhaltensweisen der Unterklassen. Die Unterklasse fügt zu den allgemeinen Eigenschaften und Verhaltensweisen der Oberklasse nur die für diese Unterklasse spezifischen Dinge hinzu oder definiert das von der Oberklasse geerbte Verhalten neu. Die Unterklasse ist eine Spezialisierung der Oberklasse. Bei der Klassifikation von Objekten muss also nach Ähnlichkeiten und Unterschieden gefragt werden.

Die Unterklasse erbt von der Oberklasse

Image       die Eigenschaften (Attribute, Daten) und

Image       das Verhalten (die Methoden).

Image

Hinweis

Einige wissen vielleicht schon, dass Objekte in der Regel nur von Klassen erzeugt werden sollen, die am Ende der Vererbungshierarchie stehen. Auch werden manche das Schlüsselwort virtual vermissen. Dafür werden zunächst die Grundlagen Schritt für Schritt erklärt. Erst danach folgt alles andere.

Image

Wenn eine Oberklasse bekannt ist, brauchen in einer zugehörigen Unterklasse nur die Abweichungen beschrieben zu werden. Alles andere kann wieder verwendet werden, weil es in der Oberklasse bereits vorliegt. Die Einbindung der Oberklasse wird durch »: public« ausgedrückt (siehe Syntaxdiagramm 6.1). »: public« kann als »ist ein« oder »ist eine Art« gelesen werden.

Abbildung 6.1: Syntaxdiagramm: »: public« kennzeichnet Vererbung.

Die Listings 6.1 und folgende zeigen das Fahrzeug/Fahrrad/Auto-Beispiel als C++-Code. Er soll einerseits compilier- und ausführbar sein, andererseits aber auch möglichst einfach und kompakt. Deswegen wird ausnahmsweise alles in eine Datei gepackt (cppbuch/k6/einfachevererbung.cpp), die auszugsweise erklärt wird.

Jedes Objekt objAbgeleitet vom Typ Abgeleitet enthält ein (anonymes) Objekt vom Typ Oberklasse, hier Subobjekt genannt, das entsprechend Speicher belegt. Dieses Subobjekt wird noch vor der Erzeugung von objAbgeleitet durch impliziten Aufruf des Oberklassenkonstruktors gebildet. Ein Objekt vom Typ Fahrrad enthält also ein Subobjekt des Typs Fahrzeug. Der Speicherplatzbedarf für ein Objekt vom Typ Fahrrad enthält damit Platz für das Oberklassenattribut radZahl. In der Klasse Fahrrad wird dieses Attribut deswegen nicht aufgeführt. Ein Fahrrad-Objekt kann wie folgt verwendet werden:

Der Compiler sucht in der Klasse Fahrrad die Methode anzahlRaeder(). Wenn er sie nicht findet, wie in diesem Fall, sucht er in der Oberklasse. Da wird er fündig und ruft die Methode auf. Deswegen enthält die Klasse Fahrrad keine Methode anzahlRaeder().

Eine abgeleitete Klasse kann Erweiterungen der Daten und zusätzliche Methoden enthalten, die keinen Bezug zur Oberklasse haben. In diesem Beispiel sind es das Attribut verbrauch und die zugehörige Methode. Ein Auto-Objekt kann so verwendet werden:

Hier findet der Compiler schon in der Klasse Auto die Methode anzahlRaeder(), eine Suche in der Oberklasse entfällt. Weil die Methode in ihrer Signatur (Kombination des Funktionsnamens mit der Reihenfolge und den Typen der Parameterliste, siehe auch Glossar, Seite 984) mit der gleichnamigen Funktion in der Oberklasse übereinstimmt, verdeckt die Methode diejenige der Oberklasse Fahrzeug. Die »Besonderheit« der Klasse Auto ist nur die Ausgabe mit cout. Der Aufruf einer Operation für ein Objekt lässt nicht erkennen, ob sie der Klasse des Objekts oder einer Oberklasse zugeordnet ist.

Falls die Vererbung von Eigenschaften und Verhaltensweisen auf mehrere Oberklassen zurückgeführt wird, spricht man von Mehrfachvererbung (englisch multiple inheritance).

Klasse als Subtyp

Eine Klasse ist ein Datentyp in C++. Eine abgeleitete Klasse kann als Subtyp der Oberklasse aufgefasst werden. Ein Objekt objAbgeleitet der abgeleiteten Klasse ist zuweisungskompatibel zu einem Objekt objOberklasse der Oberklasse. Die Zuweisung

objOberklasse = objAbgeleitet; // möglicher Datenverlust

kopiert den Inhalt des in objAbgeleitet enthaltenen Subobjekts vom Typ Oberklasse nach objOberklasse. Die nur zu objAbgeleitet gehörenden spezifischen Daten werden nicht kopiert, weil in objOberklasse dafür kein Platz vorgesehen ist. Die Umkehrung objAbgeleitet = objOberklasse ist nicht möglich, weil der Abgeleitet-spezifische Teil undefiniert bleiben würde.

Klassen für grafische Objekte

Im Folgenden wird die Basisklasse GraphObj (grafisches Objekt) als etwas umfangreicheres Beispiel verwendet. Sie wird in abgeleiteten Klassen (wie Linie, Rechteck, Dreieck) benutzt. Das Beispiel wird nach und nach entwickelt, ist also Änderungen unterworfen. Alle möglichen auf dem Bildschirm sichtbaren Dinge sind grafische Objekte. Gemeinsam soll allen Objekten sein, dass jedes Objekt einen Bezugspunkt referenzkoordinaten in Pixelkoordinaten hat. Der Bezugspunkt soll nur über die Methode bezugspunkt(Ort) veränderbar sein, andererseits sollen die Koordinaten des Bezugspunkts von anderen gelesen werden können. Die Methode bezugspunkt(Ort) (mit Parameter) hat kein [[nodiscard]]-Attribut, damit man den Bezugspunkt ändern kann und die Freiheit hat, den Rückgabewert zu ignorieren. Die Klasse GraphObj ist recht einfach. Das enthaltene Objekt referenzkoordinaten ist vom Typ Ort, der auf Seite 187 beschrieben ist. Nach GraphObj folgt eine Klasse Strecke, die von GraphObj erbt.

Alle Methoden sind wegen ihrer Kürze inline. Variablen vom Typ Ort und vom Typ GraphObj sind kleine Objekte und werden daher per Wert statt per Referenz übergeben. Innerhalb der am Ende der Header-Datei definierten globalen Funktion entfernung(GraphObj g1, GraphObj g2) wird die schon vorher in Ort.h für die Bezugspunkte des Typs Ort definierte gleichnamige Funktion aufgerufen. Der Compiler erkennt die richtige Funktion an Anzahl und Typ der Parameter. Eine kleine Besonderheit besteht darin, dass die Methode bezugspunkt() überladen ist. Wenn sie ohne Argument aufgerufen wird, gibt sie den Bezugspunkt zurück. Wenn sie mit einem Ort als Argument aufgerufen wird, setzt sie diesen Ort als neuen Bezugspunkt, gibt aber den vorherigen Bezugspunkt zurück. Diese Technik wird beim Setzen von Attributen häufig verwendet, weil sie einem aufrufenden Programm die Möglichkeit gibt, ein Attribut zu ändern und sich dabei den alten Wert zu merken, um ihn später wieder einzusetzen. Der Rückgabewert kann natürlich auch verworfen werden.

Die Fläche eines allgemeinen grafischen Objekts ist eigentlich nicht 0, sondern undefiniert. Auf diese Besonderheit wird in Abschnitt 6.5.2 eingegangen. Bis dahin bietet die Funktion flaeche() eine Standardimplementation für abgeleitete Klassen. Damit ist klar, dass diese Funktion in einer abgeleiteten Klasse möglicherweise neu definiert werden muss, nicht in der folgend besprochenen Klasse Strecke, wohl aber in einer Klasse Rechteck. Eine Strecke ist ein GraphObj. In der Klassendeklaration wird diese Beziehung syntaktisch durch : public und den Namen der Oberklasse ausgedrückt.

Listing 6.7: Klasse Strecke (cppbuch/k6/erben/Strecke.h)

#ifndef STRECKE_H #define STRECKE_H #include "GraphObj.h" class Strecke : public GraphObj { // erben von GraphObj public: Strecke(Ort ort1, Ort ort2) // Initialisierung mit Initialisierungsliste: : GraphObj{ort1}, // Initialisierung des Subobjekts, siehe Abschnitt 6.1 endpunkt{ort2} // Initialisierung des Attributs { } // leerer Code-Block [[nodiscard]] auto laenge() const { return entfernung(bezugspunkt(), endpunkt); } private: Ort endpunkt; // zusätzlich: 2. Punkt der Strecke // (der erste ist GraphObj::Referenzkoordinaten) }; #endif

6.1 Vererbung und Initialisierung

Auf Seite 303 wird darauf hingewiesen, dass jedes Objekt einer abgeleiteten Klasse ein anonymes Subobjekt der Oberklasse enthält. Der Oberklassenkonstruktor soll bei der Initialisierung eines Objekts explizit aufgerufen werden. Nur ein Endpunkt der Strecke wird als Attribut angegeben, der andere wird geerbt (Attribut GraphObj::referenzkoordinaten).

Der Konstruktor benötigt zwei Punkte zur Konstruktion der Strecke. Weil ein Objekt der Klasse Strecke ein anonymes Subobjekt der Klasse GraphObj enthält, ist der Anfangspunkt bereits durch die Referenzkoordinaten gegeben und es ist nur noch ein Endpunkt als Attribut notwendig. Falls es einen Standardkonstruktor für die Klasse GraphObj gäbe, bräuchte man das Subobjekt nicht zu initialisieren und könnte den Konstruktor der Klasse Strecke wie folgt schreiben:

// nur mit Standardkonstruktor GraphObj() möglich, aber nicht empfehlenswert Strecke(Ort ort1, Ort ort2) { bezugspunkt(ort1); // geerbter Code der Oberklasse endpunkt = ort2; }

Es gibt aber keinen Standardkonstruktor GraphObj(). Außerdem ist die Initialisierung mit einer Initialisierungsliste generell vorzuziehen, weil das Objekt in einem Schritt mit den richtigen Werten initialisiert wird (siehe Strecke.h oben).

Die Initialisierung innerhalb des Blocks {...} ist aufwendiger, weil es zwei Schritte sind: Zuerst werden die Konstruktoren für alle Objektelemente vor Betreten des Blocks aufgerufen. Anschließend werden die Daten innerhalb des Blocks zugewiesen. Dasselbe gilt für die Initialisierung von Subobjekten, wie hier für das in einem Strecke-Objekt enthaltene Subobjekt des Typs GraphObj. Die Initialisierungsliste darf enthalten:

Image       Konstruktoraufruf der Oberklasse;

Image       Elemente der Klasse selbst, aber keine geerbten Elemente.

Nach dem folgenden Abschnitt über Zugriffsschutz wird das Beispiel wieder aufgegriffen.

6.2 Zugriffsschutz

Unter Zugriffsschutz ist die Abstufung von Zugriffsrechten auf Daten und Elementfunktionen zu verstehen. Bisher sind zwei Fälle bekannt:

Image       public
Elemente und Methoden unterliegen keiner Zugriffsbeschränkung.

Image       private
Elemente und Methoden sind ausschließlich innerhalb der Klasse zugreifbar sowie für friend-Klassen und -Funktionen.

Die Zugriffsspezifizierer private und public gelten genauso in einer Vererbungshierarchie. Um abgeleiteten Klassen gegenüber der »Öffentlichkeit« weitgehende Rechte einräumen zu können, ohne den privaten Status mancher Elemente aufzugeben, gibt es einen weiteren Zugriffsspezifizierer:

Image       protected
Elemente und Methoden sind in der eigenen und in allen abgeleiteten Klassen zugreifbar, nicht aber in anderen Klassen oder außerhalb der Klasse. protected-Methoden und Elemente werden nur selten gebraucht und sollten vermieden werden, wenn es nicht gute Gründe dafür gibt.

Vererbung von Zugriffsrechten

Gegeben sei eine Oberklasse, von der eine weitere Klasse abgeleitet wird. Für die Vererbung der Zugriffsrechte gelten folgende Regeln, die weiter unten anhand einiger Beispiele verdeutlicht werden:

Image       private-Elemente sind in einer abgeleiteten Klasse nicht zugreifbar.

Image       In allen anderen Fällen gilt das jeweils restriktivere Zugriffsrecht, bezogen auf die Zugriffsrechte für ein Element und die Zugriffskennung der Vererbung einer Klasse. Beispiel: Ein protected-Element einer private-vererbten Klasse ist private in der abgeleiteten Klasse. Typischerweise werden jedoch Oberklassen public vererbt, sodass die Zugriffsrechte von Oberklassenelementen in abgeleiteten Klassen erhalten bleiben.

Tabelle 6.1 zeigt die Vererbung von Zugriffsrechten für den häufigen Fall der public-Vererbung. Die weniger gebräuchliche private- und protected-Vererbung werden Sie in Abschnitt 6.10 kennenlernen.

Tabelle 6.1: Zugriffsrechte bei public-Vererbung

Zugriffsrecht in der Basisklasse

Zugriffsrecht in einer abgeleiteten Klasse

private

kein Zugriff

protected

protected

public

public

Wenn anstatt class das Schlüsselwort struct geschrieben wird, ist die Voreinstellung public. Im Grunde ist »struct s {« nur eine Abkürzung für »class s { public:«. Zugriffskonflikte entfallen, wenn überall class durch struct ersetzt wird – dann aber verletzt man das Prinzip der Datenkapselung! Besser ist es, sich genau zu überlegen, auf welche Daten und Funktionen der Oberklasse eine Klasse zugreifen darf, und im Zweifelsfall restriktiver vorzugehen. Das folgende Beispiel zeigt typische Möglichkeiten, die sich aus den Regeln ergeben. Alle Programmzeilen, die einen Zugriffsfehler ergeben, sind markiert.

Verdecken von Funktionen verschiedener Signatur

In Listing 6.4 verdeckt die Funktion anzahlRaeder() diejenige der Oberklasse, die die gleiche Parameterliste hat. Aber auch gleichnamige Funktionen mit unterschiedlichen Parametern verdecken andere, wie das Listing 6.9 zeigt:

Ohne using Basis::g; wird die Anweisung abgeleitet.g(7); in main() vom Compiler als fehlerhaft bemängelt, weil er keine passende Funktion für den Aufruf findet. Die Funktion Basis::g(int) wird dann von Abgeleitet::g() verdeckt! Sie wird erst wieder sichtbar, wenn sie mit der using-Deklaration using Basis::g; in der abgeleiteten Klasse bekannt gemacht wird, und ist dann in main() aufrufbar. Das funktioniert auch, wenn Basis::g(int) protected ist.

6.3 Typbeziehung zwischen Ober- und Unterklasse

Eine abgeleitete Klasse kann als Subtyp der Oberklasse aufgefasst werden (siehe Seite 304). Daher ist ein Objekt der abgeleiteten Klasse zuweisungskompatibel zu einem Objekt der Oberklasse:

GraphObj g(O1); // O1, O2, O3 = Objekte vom Typ Ort Strecke s(O2, O3); g = s; // Zuweisung mit Informationsverlust

Eine Strecke wird einem GraphObj zugewiesen, explizites Typumwandeln ist zwar möglich, aber nicht notwendig. Die Wirkung ist wie

g.referenzkoordinaten = s.referenzkoordinaten;

Eine direkte Zuweisung wäre natürlich wegen private nicht möglich. Der Endpunkt der Strecke wird nicht kopiert, da er in einem GraphObj nicht vorhanden ist. Es gibt also einen Informationsverlust. Die umgekehrte Zuweisung ist nicht möglich, weil dann Informationen undefiniert blieben: Die Zuweisung eines GraphObj-Objekts an ein Strecke-Objekt würde den zweiten Endpunkt undefiniert lassen.

Die Entfernung zweier Strecken s1 und s2 kann direkt berechnet werden, wobei die jeweiligen Bezugspunkte zugrunde gelegt werden:

double entf = entfernung(s1, s2);

Aufgerufen wird hier die in GraphObj.h deklarierte freie Funktion

double entfernung(const GraphObj& g1, const GraphObj& g2);

Innerhalb der Funktion wird nur auf die GraphObj-Anteile der Strecke-Objekte zugegriffen. Sie enthalten den Bezugspunkt. Der Informationsverlust – der Endpunkt der Strecke wird nicht übergeben – spielt keine Rolle.

Die Typbeziehung zwischen Basisklasse und abgeleiteter Klasse kann umgangssprachlich am Beispiel verdeutlicht werden: Alle Tannen sind Bäume, aber das Umgekehrte (alle Bäume sind Tannen) gilt nicht. Dabei sind die Tannen Exemplare der Unterklasse und Bäume Exemplare der Oberklasse. Für Zeiger und Referenzen gilt entsprechend:

GraphObj& rg {g}; GraphObj* pg; Strecke& rs {s}; Strecke* ps {&s}; rg = rs; // erlaubte Zuweisung pg = ps; // erlaubte Zuweisung

Zeiger und Referenzen vom Oberklassentyp (pg, rg) beziehen sich auf das im Objekt s enthaltene anonyme Subobjekt vom Typ GraphObj.

Image

Hinweis

Die Typbeziehung kann nicht auf Arrays übertragen werden! Auch wenn GraphObj Oberklasse von Strecke ist, folgt daraus nicht, dass ein GraphObj-Array Oberklasse eines Strecke-Arrays ist – ein C-Array ist gar keine Klasse. Obwohl syntaktisch korrekt (d.h. compilierbar), ist die folgende Anweisung daher falsch:

Oberklasse* array = new Unterklasse[4]; // sinnlos.

Der Compiler sieht bei array nur den statischen Typ. Die nicht geerbten Attribute eines Unterklassenobjekts sind nicht zugreifbar, weil nur die Oberklassenanteile gespeichert werden. Dieses Verhalten wird im Englischen object slicing genannt. Der Begriff ist etwas irreführend, weil nichts abgeschnitten wird (slice). Tatsächlich wird bei der Konstruktion nur der Oberklassenanteil berücksichtigt, so dass der Konstruktor als Typumwandlungskonstruktor wirkt. Die Adressen &array[i] (0 ≤ i < 4) liegen nur sizeof(Oberklasse) Bytes auseinander, nicht sizeof(Unterklasse).

Image

6.4 Oberklassen-Schnittstelle verwenden

Abgeleitete Klassen können die in der Oberklasse spezifizierten Methoden verwenden, zum Beispiel die Methoden bezugspunkt() und flaeche(). Eine Folgerung der durch die public-Vererbung repräsentierten Ist-ein-Beziehung besteht darin, dass für eine Klasse alles möglich sein soll, was für die Oberklasse möglich ist, wenn auch vielleicht mit Anpassungen. Der Code der Oberklasse kann dabei wiederverwendet werden. Der wichtigste Aspekt ist die Bereitstellung von Schnittstellen für alle abgeleiteten Klassen. Ein kleines Programm zeigt, was mit den bisherigen Deklarationen und Definitionen für die Klassen GraphObj und Strecke möglich ist.

Listing 6.10: Beispiel mit Klasse Ort (cppbuch/k6/erben/main.cpp)

#include "Rechteck.h" #include "Strecke.h" using namespace std; int main() { // Definition zweier grafischer Objekte constexpr Ort nullpunkt; GraphObj g0(nullpunkt); // 1 Ort einOrt(10, 20); const GraphObj g1(einOrt); // 2 // Ausgabe beider Bezugspunkte auf verschiedene Art cout << "g0.getX()˽=˽" << g0.getX() << ’\n’; cout << "g0.getY()˽=˽" << g0.getY() << ’\n’; const Ort ort = g1.bezugspunkt(); cout << "ort.getX()˽=˽" << ort.getX() << ’\n’; cout << "ort.getY()˽=˽" << ort.getY() << ’\n’; cout << "Entfernung˽=˽" << entfernung(g0, g1) << ’\n’; cout << "neuer˽Bezugspunkt˽für˽g0:\n"; g0.bezugspunkt(ort); // Rückgabewert wird hier ignoriert cout << "g0.bezugspunkt()˽=˽"; anzeigen(g0.bezugspunkt()); // Ort.h, siehe Seite 186 cout << "\n˽Entfernung˽=˽" << entfernung(g0, g1) << ’\n’; constexpr Ort anf; const Strecke s1(anf, ort); cout << "Strecke˽von˽"; anzeigen(anf); cout << "˽bis˽"; anzeigen(ort); cout << "\n˽Fläche˽der˽Strecke˽s1˽=˽" << s1.flaeche() << ’\n’; // geerbt cout << "Länge˽der˽Strecke˽s1˽=˽" << s1.laenge() << ’\n’; // zusätzliche Methode einOrt = Ort(20, 30); // Neuzuweisung constexpr Ort ort2(100, 50); const Strecke s2(einOrt, ort2); cout << "Entfernung˽der˽Bezugspunkte:˽" << entfernung(s1.bezugspunkt(), s2.bezugspunkt()) << ’\n’; cout << "Entfernung˽der˽Strecken˽s1,˽s2˽=˽" << entfernung(s1, s2) << ’\n’; const Rechteck rechteck(Ort(0, 0), 20, 50); cout << "rechteck.flaeche˽=˽" << rechteck.flaeche() << ’\n’; // 1000 cout << rechteck.GraphObj::flaeche(); // 0 ! }

Am Ende des Listings 6.10 wird zur Berechnung der Entfernung einmal

entfernung(s1.bezugspunkt(), s2.bezugspunkt())

aufgerufen, also double entfernung(Ort, Ort) aus Ort.h. Anschließend wird die Entfernung noch einmal ausgegeben, wobei jetzt die Strecken s1 und s2 direkt als Aufrufparameter dienen. Wie kann das angehen, wo doch bisher keine Funktion zur Entfernungsberechnung mit Strecke-Objekten in der Parameterliste beschrieben wurde? Der Grund liegt in der Typbeziehung zwischen Basisklasse und abgeleiteter Klasse.

6.4.1 Konstruktor erben

Ein Konstruktor kann wiederverwendet werden, indem er in einem anderen Konstruktor zur Anwendung kommt (Delegation). Eine andere Möglichkeit der Wiederverwendung von Konstruktor-Code ist das Erben von Konstruktoren. Das bietet sich immer dann an, wenn

Image       die abgeleitete Klasse keine weiteren Attribute hat (denn diese könnten nicht von geerbten Konstruktoren initialisiert werden), und wenn

Image       die Attribute der Oberklasse nicht schon vorher initialisiert wurden, zum Beispiel bei der Deklaration.

Dass die Konstruktoren der Oberklasse geerbt werden sollen, wird durch eine using-Deklaration bewirkt. Ein Beispiel:

Wenn der Oberklassenname einen Scope-Operator :: enthält (ist hier nicht der Fall), muss ein Hilfsname konstruiert werden:

class A : public std::vector<int> { // Konstruktor der Oberklasse std::vector<int> erben: using std::vector<int>::std::vector<int>; // funktioniert nicht using hilf = std::vector<int>; // Hilfsname using hilf::hilf; // so geht’s // ... };
6.5 Überschreiben von Funktionen in abgeleiteten Klassen

In diesem Abschnitt geht es um das Überschreiben von Funktionen innerhalb einer Vererbungshierarchie. Dieser Abschnitt zeigt die Wirkungsweise für Elementfunktionen mit derselben Schnittstelle in abgeleiteten Klassen. Sie redefinieren die Funktion, das heißt, sie stellen eine andere Implementation als die Oberklasse zur Verfügung. Bei gleichnamigen Funktionen werden die folgenden Begriffe verwendet:

Image       Überladen meint das Definieren verschiedener Funktionen mit demselben Namen, aber unterschiedlicher Schnittstelle. Sie kennen das von Abschnitt 2.2.5.

Image       Verdecken einer Funktion meint, dass eine Funktion mit gleicher oder auch verschiedener Schnittstelle vom Compiler nicht gesehen wird, weil eine gleichnamige Funktion in einem inneren Gültigkeitsbereich oder einer Unterklasse existiert. Ein Beispiel ist die Funktion anzahlRaeder() in Listing 6.4, ein anderes sehen Sie in Listing 6.9.

Image       Überschreiben bedeutet, dass eine Funktion derselben Signatur über einen Zeiger oder eine Referenz der Oberklasse aufgerufen werden kann.

Die Methoden bezugspunkt() und flaeche() der Oberklasse GraphObj können auch für die abgeleitete Klasse Strecke verwendet werden. Falls die gleiche Bedeutung gemeint ist, aber ein anderer Mechanismus zugrunde liegt, können Funktionen redefiniert werden. Um das zu zeigen, wird eine Klasse Rechteck eingeführt:

Am Beispiel der Flächenberechnung mit der Funktion flaeche() sehen wir das Prinzip des Verdeckens:

Rechteck rechteck(Ort(0,0), 20, 50); cout << "rechteck.flaeche˽=˽" << rechteck.flaeche() << ’\n’; // 1000

Dieses Mal wird nicht wie bei der Klasse Strecke als Ergebnis 0 ausgegeben, sondern der Zahlenwert 1000. Die Funktion verdeckt GraphObj::flaeche(). Wenn aus irgendwelchen Gründen (in diesem Beispiel nicht sinnvoll) dennoch die Oberklassenfunktion aufgerufen werden soll, müssen der Klassenname und der Bereichsoperator :: angegeben werden:

cout << rechteck.GraphObj::flaeche(); // 0 !

Im Gegensatz zu den überladenen Funktionen können überschreibende Funktionen in abgeleiteten Klassen die gleiche Signatur haben, weil der Compiler sich die Klasse zusätzlich zur Signatur merkt. Das normale Überladen ist weiterhin möglich. Es gibt einen weiteren Unterschied: Das Überladen von Nicht-Elementfunktionen funktioniert nur innerhalb desselben Gültigkeitsbereichs, während die überschreibenden Elementfunktionen in verschiedenen Klassen und damit unterschiedlichen Gültigkeitsbereichen sind.

Polymorphismus

Polymorphismus heißt auf Deutsch Vielgestaltigkeit. Damit ist in der objektorientierten Programmierung die Fähigkeit einer Variable gemeint, zur Laufzeit eines Programms auf verschiedene Objekte zu verweisen. Anders formuliert: Erst zur Laufzeit eines Programms wird die zu dem jeweiligen Objekt passende Realisierung einer Operation ermittelt. In C++ wird die Einschränkung getroffen, dass die Objekte abgeleiteten Klassen zugeordnet sind. Ein Funktionsaufruf muss irgendwann an eine Folge von auszuführenden Anweisungen gebunden werden. Wenn es erst während der Ausführung des Programms geschieht, wird der Vorgang dynamisches oder spätes Binden genannt, andernfalls statisches oder frühes Binden. Eine zur Laufzeit ausgewählte Methode heißt virtuelle Funktion. Trotz der äußerlichen Ähnlichkeit und der ähnlichen Absicht dahinter sind Überladen und Polymorphismus verschiedene Konzepte. Virtuelle Funktionen haben dieselbe Schnittstelle in allen abgeleiteten Klassen, andernfalls wären sie überflüssig.

Hinweis für angehende Informatiker: Es gibt mehrere Arten von Polymorphismus. Die bekannteste Klassifikation ist die von Cardelli und Wegner [CW]. Der Polymorphismus, um den es in diesem Kapitel geht, heißt entsprechend dieser Klassifikation Subtyp-Polymorphismus oder Inklusions-Polymorphismus. Templates fallen unter den Begriff statischer oder parametrischer Polymorphismus. Auf die anderen Arten gehe ich nicht weiter ein. Wenn der Begriff Polymorphismus ohne weitere Erklärung gebraucht wird, ist meistens Inklusions-Polymorphismus gemeint.

6.5.1 Virtuelle Funktionen

Möglicherweise tritt der Fall ein, dass erst zur Laufzeit entschieden werden soll, welches Objekt angesprochen wird. Damit wird auch erst zur Laufzeit bestimmt, welche (Element-) Funktion verwendet werden soll. In abgeleiteten Klassen können für solche Fälle virtuelle Funktionen der Basisklassen überschrieben werden.

Diese Funktionen müssen in diesem Fall die gleiche Signatur haben. Sie werden mit dem Schlüsselwort virtual gekennzeichnet. Der Rückgabetyp einer virtuellen Funktion in einer abgeleiteten Klasse muss mit dem Rückgabetyp in der Basisklasse übereinstimmen (Spezialisierungen sind dabei möglich, siehe unten).

Die Deklaration einer Funktion als virtual bewirkt, dass Objekten indirekt die Information über den Objekttyp mitgegeben wird. Dies wird realisiert, indem vom Compiler im Speicherbereich eines Objekts zusätzlich zu den Objektattributen ein Zeiger vptr auf eine besondere Tabelle vtbl (virtual table = Tabelle von Zeigern auf virtuelle Funktionen) eingebaut wird. Die Tabelle gehört zu der Klasse des Objekts und enthält ihrerseits Zeiger auf die virtuellen Funktionen dieser Klasse.

Wenn nun eine virtuelle Funktion über einen Zeiger oder eine Referenz auf dieses Objekt angesprochen wird, weiß das Laufzeitsystem, dass die Funktion über den Zeiger vptr in der Tabelle gesucht und angesprochen werden muss. Es wird damit die zu diesem Objekt gehörende Funktion aufgerufen. Wenn die Klasse dieses Objekts aber keine Funktion mit gleicher Signatur hat, wird die entsprechende Funktion der Oberklasse gesucht und aufgerufen. Um den internen Mechanismus muss man sich nicht kümmern. Es genügt zu wissen, dass Objekte durch den versteckten Zeiger vptr etwas größer werden und dass der Zugriff auf virtuelle Funktionen durch den Umweg über die Zeiger geringfügig länger dauert. Oft kann der Zugriff auf eine virtuelle Funktion bereits statisch aufgelöst werden, sodass der Compiler in der Lage ist, den Zugriff zu optimieren. Um den Unterschied zwischen virtuellen und nicht-virtuellen Funktionen herauszuarbeiten, vergleiche ich beide Varianten anhand des bekannten Beispiels.

Verhalten einer nicht-virtuellen Funktion

Rufen wir uns die überschriebenen Funktionen flaeche() des obigen Beispiels in Erinnerung:

class GraphObj { // .... auto flaeche() const { return 0.0;} // nicht virtuell };

Die Fläche eines allgemeinen grafischen Objekts ist eigentlich nicht 0, sondern undefiniert. In Abschnitt 6.5.2 wird darauf eingegangen.

class Rechteck : public GraphObj { // .... auto flaeche() const // nicht virtuell, verdeckt GraphObj::flaeche() { return static_cast<double>(dieHoehe) * dieBreite; } };

Wir definieren ein grafisches Objekt graphObj, ein Rechteck R und einen Zeiger graphObj-Ptr, den wir auf graphObj zeigen lassen:

GraphObj graphObj(Ort(20, 20)); Rechteck rechteck(Ort(100, 100), 20, 50); // (x, y), Höhe, Breite GraphObj *graphObjPtr; // Zeiger auf graphObj

Nun wird die Fläche beider Objekte ausgegeben. Dazu wird der Zeiger zuerst auf das grafische Objekt graphObj gerichtet. Über graphObjPtr wird die Funktion flaeche() aufgerufen. Dann (zur Programmlaufzeit!) wird graphObjPtr auf das Rechteck rechteck gerichtet und der Aufruf wiederholt. Zum Vergleich wird rechteck.flaeche() angezeigt:

graphObjPtr = &graphObj; // Zeiger auf graphObj richten cout << "graphObjPtr->flaeche()˽=" << graphObjPtr->flaeche() << ’\n’; graphObjPtr = &rechteck; // Zeiger auf Rechteck richten cout << "graphObjPtr->flaeche()˽=" << graphObjPtr->flaeche() << ’\n’; cout << "rechteck.flaeche()˽=" << rechteck.flaeche() << ’\n’;

Was geschieht? Zweimal ergibt sich der Wert 0 und nur im dritten Aufruf der korrekte Wert 1000, obwohl graphObjPtr auf das Rechteck zeigt. Weil der Zeiger graphObjPtr vom Typ »Zeiger auf GraphObj« ist und keine Information über das Objekt hat, auf das er verweist, wird im ersten und im zweiten Fall GraphObj::flaeche() aufgerufen. Im zweiten Fall wird das anonyme Subobjekt vom Typ GraphObj angesprochen, das innerhalb des rechteck-Objekts liegt.

Verhalten einer virtuellen Funktion

Der Einsatz virtueller Funktionen bewirkt, dass Objekten die Typinformation über sich mitgegeben wird. Um das zu zeigen, erweitern wir das Beispiel um eine virtuelle Funktion v_flaeche():

class GraphObj { // .... virtual double v_flaeche() const { return 0.0;} };
class Rechteck : public GraphObj { // .... virtual double v_flaeche() const override { return static_cast<double>(hoehe) * breite; } };

Der Spezifizierer override kann entfallen, aber er ist hilfreich: Er bedeutet, dass die damit gekennzeichnete Methode eine Methode der Oberklasse überschreibt. Wenn zum Beispiel v_flaeche() versehentlich falsch geschrieben wäre, etwa Flaeche(), würde der Compiler ohne override annehmen, dass es sich um eine gänzlich neue Methode handelt. Mit override gäbe es eine Fehlermeldung und damit die Chance zur Korrektur.

Image

Tipp

Benutzen Sie bei allen virtuellen Methoden, die eine Methode der Oberklasse überschreiben, den Spezifizierer override. Manche Schreibfehler werden so leicht entdeckt. Der Spezifizierer virtual ist dann überflüssig. Eine Funktion, die eine virtual-Funktion der Oberklasse überschreibt, ist automatisch virtual.

Image

Image

Hinweis

auto kann nicht als Rückgabetyp virtueller Funktionen verwendet werden.

Image

Virtuelle Funktionen sind auch in allen abgeleiteten Klassen virtuell. Das Schlüsselwort virtual muss nur in der Basisklasse angegeben werden. Zu Dokumentationszwecken kann man es jeweils hinzuschreiben, wichtiger ist aber die Kennzeichnung in den abgeleiteten Klassen mit override. Das obige Beispiel wird jetzt mit der Funktion v_flaeche() in genau der gleichen Art und Weise wiederholt:

graphObjPtr = &graphObj; // Zeiger auf graphObj richten cout << "graphObjPtr->v_flaeche()˽=" << graphObjPtr->v_flaeche() << ’\n’; graphObjPtr = &rechteck; // Zeiger auf Rechteck richten cout << "graphObjPtr->v_flaeche()˽=" << graphObjPtr->v_flaeche() << ’\n’;

Jetzt erhalten wir als Ergebnis 0 im ersten Fall (wie vorher), aber 1000 im zweiten Fall. Zur Laufzeit des Programms wird der Zeiger auf verschiedene Objekte gerichtet, und es wird die zum jeweiligen Objekt passende Funktion aufgerufen, nämlich im zweiten Fall Rechteck::v_flaeche().

Ein Unterschied im Verhalten eines Objekts durch Aufruf einer virtuellen Funktion im Vergleich zu nicht-virtuellen Funktionen zeigt sich nur, wenn der Aufruf durch Oberklassenzeiger oder -referenzen geschieht statt über den Objektnamen. Im letzteren Fall gibt es ja ohnehin keine Zweifel über den Typ.

Eigenschaften virtueller Funktionen

Als wesentliche Merkmale virtueller Funktionen lassen sich zusammenfassen:

Image       Virtuelle Funktionen dienen zum Überschreiben bei gleicher Signatur und bei gleichem Rückgabetyp. Erlaubte Erweiterung: Wenn der Rückgabetyp einer virtuellen Funktion eine Referenz auf eine Klasse ist, dann darf der Rückgabetyp der entsprechenden Funktion in der abgeleiteten Klasse eine Referenz auf die abgeleitete Klasse sein. Das Gleiche gilt für Zeiger anstelle von Referenzen.

Image       Der Aufruf einer nicht-virtuellen Elementfunktion hängt vom Typ des Zeigers ab, über den die Funktion aufgerufen wird, während der Aufruf einer virtuellen Elementfunktion vom Typ des Objekts abhängt, auf das der Zeiger verweist. Der Aufruf von virtuellen Funktionen über Basisklassenzeiger oder -referenzen, die auf ein Objekt einer abgeleiteten Klasse zeigen, bezieht sich auf die genau zu diesem Objekt passende Funktion.

Image       Eine in einer Basisklasse als virtual deklarierte Funktion definiert eine Schnittstelle für alle abgeleiteten Klassen, auch wenn diese zum Zeitpunkt der Festlegung der Basisklasse noch unbekannt sind. Ein Programm, das Zeiger oder Referenzen auf die Basisklasse benutzt, kann damit sehr leicht um abgeleitete Klassen erweitert werden, weil der Aufruf einer virtuellen Funktion über Zeiger oder Referenzen sicherstellt, dass die zum referenzierten Objekt gehörende Realisierung der Funktion aufgerufen wird.

Image       Der vorstehende Punkt gilt auch für Destruktoren. Wenn es überhaupt virtuelle Funktionen in einer Klasse gibt, ist die Klasse zur Vererbung gedacht. Der Destruktor muss dann ebenfalls als virtual deklariert werden. Die Definition der Klasse GraphObj muss um eine der Zeilen

virtual ~GraphObj() {}

oder

virtual ~GraphObj() = default;

erweitert werden. Destruktoren in abgeleiteten Klassen, ob systemgeneriert oder selbstgeschrieben, sind damit automatisch auch virtual. Wegen der »Regel der großen Drei« (Seite 255) müssen dann auch die zwei folgenden Zeilen nachgetragen werden:

GraphObj(const GraphObj&) = delete; GraphObj& operator=(const GraphObj&) = delete;

Der systemgenerierte Kopierkonstruktor und der Zuweisungsoperator einer polymorphen Klasse können zu dem von Seite 311 bekannten »object slicing« führen – deshalb = delete.

Aus diesen Punkten lässt sich eine wichtige Regel ableiten: Nicht-virtuelle Funktionen einer Basisklasse sollen nicht in abgeleiteten Klassen überschrieben werden! Oder anders ausgedrückt:

Image

Tipp

Es kann sein, dass das Überschreiben einer Funktion möglich ist, zum Beispiel, wenn später eine Klasse geschrieben wird, die von Ihrer Klasse erbt, und das Verhalten der Funktion angepasst werden soll. Dann sollte die Funktion in der Basisklasse als virtual deklariert werden. Der Grund: Die Bedeutung (= das Verhalten) eines Programms soll sich nicht ändern, wenn auf eine Methode über den Objektnamen oder über Basisklassenzeiger bzw. -referenzen zugegriffen wird.

Image

6.5.2 Abstrakte Klassen

In vielen Fällen soll die Basisklasse einer Hierarchie sehr allgemein sein und Code enthalten, der aller Voraussicht nach nicht geändert werden muss. Es ist dann oft nicht notwendig oder gewünscht, dass Objekte dieser Klassen angelegt werden. Diese abstrakten Klassen dienen ausschließlich als Ober- oder Basisklassen. Objekte werden nur von den abgeleiteten Klassen erzeugt, die dann jeweils ein Subobjekt vom Typ der abstrakten Basisklasse enthalten. Das syntaktische Mittel, um eine Klasse abstrakt zu machen, sind rein virtuelle Funktionen (englisch pure virtual). Abstrakte Klassen haben mindestens eine rein virtuelle Funktion, die typischerweise keinen Definitionsteil hat, ihn aber haben kann. Durch die rein virtuelle Funktion wird gewährleistet, dass stets die zum Objekttyp passende Methode aufgerufen wird. Definieren einer abstrakten Klasse heißt also nichts anderes, als ein gemeinsames Protokoll für alle abgeleiteten Klassen zu definieren. Eine rein virtuelle Funktion wird durch Ergänzung mit »= 0« deklariert:

virtual int rein_virtuelle_func(int) = 0;

Unser Beispiel mit den grafischen Objekten ist wie geschaffen zur Anwendung abstrakter Klassen, denn ein grafisches Objekt ist entweder ein Rechteck, ein Polygon, ein Kreis oder was man sich sonst noch ausdenken kann, aber niemals ein grafisches Objekt »an sich«. Ein allgemeines grafisches Objekt kann nicht gezeichnet werden und hat keine definierte Fläche. Also benötigen wir in einem Programm keine Objekte der Klasse GraphObj, außer natürlich als (versteckte) Subobjekte von Rechtecken, Kreisen und so weiter. Wir können die Klasse GraphObj daher als abstrakte Klasse formulieren, indem wir flaeche() in eine rein virtuelle Funktion umwandeln:

virtual double flaeche() const = 0;

Klassen, von denen Objekte erzeugt werden können, heißen konkrete Klassen, wenn der Unterschied zu abstrakten Klassen betont werden soll. Wenn eine konkrete Klasse von einer abstrakten Klasse erbt, muss sie zu den rein virtuellen vorgegebenen Funktionsprototypen konkrete Implementierungen bereitstellen, zum Beispiel, um die Fläche als Produkt von Länge mal Breite zu berechnen. Wenn in einer vermeintlich konkreten Klasse eine Implementierung fehlt, zum Beispiel, weil sie vergessen wurde, ist sie tatsächlich nicht konkret, sondern selbst abstrakt. Die Eigenschaft »abstrakt« wird auf Klassen ohne oder mit unvollständiger Implementation vererbt. Falls versucht wird, von einer Klasse dieser Art ein Objekt zu erzeugen, gibt es eine Fehlermeldung des Compilers.

Das untenstehende Beispiel zeigt eine typische Art, abstrakte Klassen und virtuelle Funktionen einzusetzen. Wir erweitern dazu die Klasse GraphObj um eine Funktion zeichnen(), die das Objekt auf dem Bildschirm darstellen soll. Die Funktion sieht natürlich für Kreise und Rechtecke unterschiedlich aus, der Aufruf jedoch beziehungsweise die Schnittstelle sind stets gleich. Um das Beispiel nicht mit grafikspezifischen Details zu überfrachten, besteht die einzige Aufgabe der Funktion zeichnen() darin, eine Meldung auf dem Bildschirm auszugeben.

Weitere Besonderheiten des Beispiels sind wie folgt:

Image       Die Methode flaeche() ist in der Klasse GraphObj als rein virtuelle Funktion ohne Definition deklariert.

Image       Im Unterschied dazu stellt die ebenfalls rein virtuelle Methode zeichnen() eine Standarddefinition bereit, die von den abgeleiteten Klassen benutzt wird.

Image       Die Klasse Quadrat1 braucht die Funktion flaeche() nicht neu zu implementieren, weil die Implementierung von der Klasse Rechteck geerbt wird. Dies gilt auch für zeichnen(), wenn auf eine Unterscheidung bei der Ausgabe verzichtet werden soll.

Image       Die while-Schleife im main-Programm zeigt die Stärke des Polymorphismus. Ohne sich um den Typ der einzelnen Objekte zu kümmern, wird stets die richtige Funktion aufgerufen.

Der Übersichtlichkeit halber und weil später Bezug darauf genommen wird, sind die Dateien mit den Änderungen vollständig wiedergegeben.

Listing 6.15: Klasse GraphObj, 2. Variante (cppbuch/k6/abstrakt/GraphObj.h)

#ifndef GRAPHOBJ_H #define GRAPHOBJ_H #include <Ort.h> // enthält #include <iostream> class GraphObj { // Variante 2 public: GraphObj(Ort einOrt) // allgemeiner Konstruktor : referenzkoordinaten{einOrt} { } virtual ~GraphObj() = default; // virtueller Destruktor GraphObj(const GraphObj&) = delete; // Regel der großen Drei, siehe Seite 255 GraphObj& operator=(const GraphObj&) = delete; // dito // Bezugspunkt ermitteln: [[nodiscard]] auto bezugspunkt() const { return referenzkoordinaten; } auto bezugspunkt(Ort nO) // alten Bezugspunkt ermitteln und gleichzeitig neuen wählen { Ort temp{referenzkoordinaten}; referenzkoordinaten = nO; return temp; } // Koordinatenabfrage [[nodiscard]] auto getX() const { return referenzkoordinaten.getX(); } [[nodiscard]] auto getY() const { return referenzkoordinaten.getY(); } // rein virtuelle Methoden [[nodiscard]] virtual double flaeche() const = 0; virtual void zeichnen() const = 0; private: Ort referenzkoordinaten; }; // Standardimplementierung einer rein virtuellen Methode inline void GraphObj::zeichnen() const { std::cout << "Zeichnen:˽"; } // Die Entfernung zwischen 2 GraphObj-Objekten ist hier als Entfernung // ihrer Bezugspunkte (überladene Funktion) definiert. [[nodiscard]] inline auto entfernung(const GraphObj& g1, const GraphObj& g2) { return entfernung(g1.bezugspunkt(), g2.bezugspunkt()); } #endif

Die Funktion entfernung() erhält Referenzparameter. Wertparameter sind nicht mehr möglich, weil es von der nun abstrakten Klasse GraphObj keine Objekte geben kann. (Ein Wertparameter würde zum Versuch führen, den nicht vorhandenen Kopierkonstruktor für GraphObj aufzurufen, um ein Objekt dieses Typs zu erzeugen.)

Die Klassen Strecke und Rechteck müssen die rein virtuellen Methoden implementieren. Andernfalls wären die Klassen ebenfalls abstrakt und es könnte keine Instanzen von ihnen geben. Ein Endpunkt der Strecke wird von GraphObj geerbt, der andere ist Attribut der Klasse.

Das folgende Beispielprogramm ruft die Methoden der grafischen Objekte polymorph auf. Entscheidend ist nicht der (statische) Typ des Zeigers, den der Compiler sieht, sondern der polymorphe oder dynamische Typ, das heißt, der Typ des Objekts, auf das der Zeiger zur Laufzeit verweist. Die Elemente des Vektors GraphObjZeiger sind alle vom statischen Typ GraphObj*, sie verweisen aber zur Laufzeit auf Objekte von Klassen, die von GraphObj abgeleitet wurden.

Dasselbe gilt für Referenzen. So sind die Referenzen r_ref, s_ref und q_ref im Programm alle vom Typ der Basisklasse GraphObj. Die Referenzen verweisen aber zur Laufzeit auf Objekte verschiedener Typen, nämlich der Klassen Rechteck, Strecke und Quadrat.

Das Beispiel ist sehr leicht um beliebige grafische Klassen erweiterbar (zum Beispiel Kreis, Ellipse, Polygon, ...), ohne dass die Anweisung »Zeichnen aller Objekte« überhaupt geändert werden muss.

6.5.3 Virtueller Destruktor

Ein virtueller Destruktor sorgt ähnlich wie virtuelle Funktionen dafür, dass Zeigern die Typinformation über ein Objekt zur Verfügung steht und deshalb die Speicherfreigabe exakt erfolgt. Über einen Zeiger px vom Typ »Zeiger auf Basisklasse«, der auf ein Objekt X einer abgeleiteten Klasse zeigt, kann zur Compilierzeit, also statisch, nur die Größe des Subobjekts (vom Typ Basisklasse) von X ermittelt werden. Die Operation delete auf px angewendet gäbe ohne virtuellen Destruktor Platz entsprechend sizeof(*px) frei, also zu wenig, sodass langlaufende Programme Speicherprobleme bekommen können. Interessant ist hier aber der dynamische Typ, also der Typ des Objekts X, denn für diesen Typ muss der Speicherplatz freigegeben werden. Das Beispielprogramm demonstriert die Notwendigkeit für virtuelle Destruktoren.

Listing 6.20: Beispielprogramm mit virtuellem Destruktor (cppbuch/k6/virtdest.cpp)

#include <iostream> #define PRINT(X) std::cout << (#X) << "˽=˽" << (X) << ’\n’ // siehe Seite 142 class Basis { double bWert; public: Basis(double b = 0.0) : bWert(b) {} virtual ~Basis() // virtueller Destruktor! { std::cout << "Objekt˽" << bWert << "˽Basis-Destruktor˽aufgerufen!\n"; } Basis(const Basis&) = delete; // Regel der großen Drei, siehe Seite 319 Basis& operator=(const Basis&) = delete; // dito }; class Abgeleitet : public Basis { double aWert; public: Abgeleitet(double b = 0.0, double a = 0.0) : Basis(b), aWert(a) {} ~Abgeleitet() override { std::cout << "Objekt˽" << aWert << "˽Abgeleitet-Destruktor˽aufgerufen!\n"; } Abgeleitet(const Abgeleitet&) = delete; // Regel der großen Drei, siehe Seite 319 Abgeleitet& operator=(const Abgeleitet&) = delete; // dito }; int main() { Basis* pb = new Basis(1.0); PRINT(sizeof(*pb)); Abgeleitet* pa = new Abgeleitet(2.0, 2.2); PRINT(sizeof(*pa)); Basis* pba = new Abgeleitet(3.0, 3.3); PRINT(sizeof(*pba)); std::cout << "pb˽löschen:\n"; delete pb; // ok std::cout << "pa˽löschen:\n"; delete pa; // ok std::cout << "pba˽löschen:\n"; delete pba; // ok nur mit virtuellem Destruktor! }

Die Basisklassenobjekte werden durch eine ganze Zahl, die Objekte der abgeleiteten Klasse durch eine Zahl des Typs double identifiziert. Es werden ein Basisklassenobjekt und zwei Objekte der abgeleiteten Klasse erzeugt. Im Beispielprogramm werden 4 Bytes für int, 8 Bytes für double benötigt. Der Rest geht für die Verwaltung (versteckte Zeiger, Seite 316) drauf. Es liefert die Ausgabe (die Zahlen können auf Ihrem System andere sein):

sizeof(*pb) = 16 sizeof(*pa) = 24 sizeof(*pba) = 16 pb löschen: Objekt 1 Basis-Destruktor aufgerufen! pa löschen: Objekt 2.2 Abgeleitet-Destruktor aufgerufen! Objekt 2 Basis-Destruktor aufgerufen! pba löschen: Objekt 3.3 Abgeleitet-Destruktor aufgerufen! Objekt 3 Basis-Destruktor aufgerufen!

sizeof gibt die statisch aus dem Typ des Zeigers ermittelbare Objektgröße an. delete ruft den korrekten Destruktor auch im letzten Fall auf. Ohne das Schlüsselwort virtual würde nur jeweils der Destruktor aufgerufen, der zum Typ des Zeigers passt. Ausgabe bei Fehlen des Schlüsselworts virtual:

sizeof(*pb) = 8 veränderte Werte! sizeof(*pa) = 16 sizeof(*pba) = 8

. . . und so weiter wie oben, aber es fehlt die Ausgabe

Objekt 3.3 Abgeleitet-Destruktor aufgerufen!

Man sieht daran, dass nur der Basisklassenanteil des Objekts *pba freigegeben wurde, entsprechend dem statischen Datentyp von pba. Der Rest bleibt im Speicher hängen.

Image

Merke:

Virtuelle Destruktoren sollen immer dann verwendet werden, wenn von der betreffenden Klasse abgeleitet wird oder nicht auszuschließen ist, dass von ihr zukünftig durch Ableitung neue Klassen gebildet werden. Wegen der Regel der »großen Drei« muss es dann auch einen eigenen Kopierkonstruktor und Zuweisungsoperator geben, ggf. mit = delete deklariert, wenn sie nicht gebraucht werden.

Image

Das gilt auch, wenn new und delete durch die später zu besprechenden »Smarten Zeiger« ersetzt werden, wie am Beispiel des Programms cppbuch/k6/virtdestuniqueptr.cpp (hier nicht abgedruckt) zu sehen. An den ausgegebenen, durch Streichen des Schlüsselworts virtual veränderten sizeof-Werten ist ferner zu erkennen, dass die Objekte nunmehr keine besondere Typinformation enthalten, das heißt in diesem Fall, dass die Tabelle der Zeiger auf virtuelle Funktionen nicht existiert. Der Effekt ist hier mit sizeof natürlich nur deshalb erkennbar, weil es keine weitere virtuelle Funktion gibt, die die Objektgröße verändern würde.

Immer wenn Basisklassenzeiger oder -referenzen auf dynamisch erzeugte Objekte benutzt werden und virtuelle Methoden vorhanden sind, soll ein virtueller Destruktor eingesetzt werden. Wenn eine Klasse von anderen per Vererbung genutzt werden kann, kann die Art der zukünftigen Benutzung nicht bekannt sein. Also: Destruktoren immer als virtual deklarieren, falls vererbt werden könnte! Oder, wenn Vererben nicht gewünscht ist, mit final verbieten, siehe unten.

6.5.4 Vererbung verbieten

Das Vererben einer Klasse kann mit dem Schlüsselwort final verhindert werden, um eine versehentliche polymorphe Nutzung auszuschließen.

NichtAbleitbar kann selbst von einer anderen Klasse erben, etwa:

class NichtAbleitbar final : public Oberklasse { ... };
Image

Übungen

6.1 Auf Seite 311 wurde die Funktion flaeche() für ein Objekt der Klasse Strecke aufgerufen. Ist der Aufruf auch möglich, wenn GraphObj als abstrakte Klasse definiert ist?

6.2 Schreiben Sie eine Klasse Person mit den zwei Attributen Nachname und Vorname, sowie eine Klasse StudentIn und eine Klasse ProfessorIn, die beide von Person erben. Die Klasse StudentIn soll ein Attribut »Matrikelnummer«, die Klasse ProfessorIn ein Attribut »Lehrgebiet« haben. Der Einfachheit halber seien alle Attribute vom Typ string. Fügen Sie Methoden zum Lesen der Attribute hinzu, zum Beispiel getNachname() bei der Klasse Person. Es soll auch eine Methode toString() geben, die die vollständigen Informationen liefert und deren Schnittstelle und eine Standardimplementierung in der Klasse Person definiert sind. Die Standardimplementierung soll einen aus Vor- und Nachnamen zusammengesetzten String zurückliefern. In den Unterklassen soll toString() zusätzlich den Status (StudentIn/ProfessorIn) und die Matrikelnummer bzw. das Lehrgebiet zurückgeben. Von der Klasse Person soll kein Objekt erzeugt werden können, sie sei also abstrakt. Der folgende Programmauszug zeigt die Benutzung der Klassen:

vector<shared_ptr<Person>> diePersonen; diePersonen.push_back(make_shared<StudentIn>( "Herder", "Johann˽Gottfried", "635374")); diePersonen.push_back(make_shared<ProfessorIn>( "Kant", "Immanuel", "Philosophie")); diePersonen.push_back(make_shared<StudentIn>("von˽Schön", "Theodor", "123429")); for (const auto& personPtr : diePersonen) { cout << personPtr->getVorname() << ’\n’; } for (const auto& personPtr : diePersonen) { cout << personPtr->toString() << ’\n’; }

Eine beispielhafte Verwendung von shared_ptr und make_shared finden Sie in Listing 4.48 am Ende von Kapitel 4. Die Ausgabe des Programms sei z.B.:

Johann Gottfried Immanuel Theodor Student/in Johann Gottfried Herder, Mat.Nr.: 635374 Prof. Immanuel Kant, Lehrgebiet: Philosophie Student/in Theodor von Schön, Mat.Nr.: 123429

6.3 Wie greifen Sie im obigen Programmauszug auf eine Methode der Klasse StudentIn zu, zum Beispiel auf die Methode getMatrikelnummer()?

Image

6.5.5 Private virtuelle Funktionen

Die virtuelle Funktion einer Basisklasse kann Teile bereitstellen, die für alle abgeleiteten Klassen gelten. Die jeweiligen Unterschiede werden in den abgeleiteten Klassen definiert. Diese Implementationen müssen nicht public sein. Für solche Fälle bietet sich das Design-Muster »Template Method« [GHJV] an. Dazu wird in der Basisklasse eine nichtvirtuelle Methode als Schnittstelle definiert, die die entsprechende virtuelle Methode aufruft. Dies wird am Beispiel der aus dem vorhergehenden Abschnitt bekannten Klasse GraphObj gezeigt. Aus Platzgründen sind die folgenden Listings gekürzt, sie zeigen nur die wesentlichen Unterschiede. Das Design-Muster bietet die Möglichkeit, an nur einer Stelle Vor- und Nachbedingungen zu prüfen, siehe Funktion zeichnen().

Die Funktionen flaeche() und zeichnen() sind die »Template Methods«. Sie werden vererbt und rufen die jeweilige Implementierung auf. Weil flaeche_impl() und zeichnen_impl() virtuell sind, wird die zur jeweiligen Unterklasse passende Funktion zur Laufzeit ermittelt und aufgerufen. zeichnen_impl() ist protected, weil dafür eine Standardimplementation bereitgestellt wird, die von abgeleiteten Klassen erreichbar sein muss. Bei der Funktion flaeche_impl() ist das nicht der Fall, sie kann also private sein. Die abgeleiteten Klassen enthalten die Definition der Methoden, hier gezeigt am Beispiel der Klasse Rechteck:

6.6 Probleme der Modellierung mit Vererbung

Dass Vererbung die geeignete programmiertechnische Umsetzung einer Ist-ein- oder Isteine-Art-Beziehung zwischen Objekten ist, kann durchaus fraglich sein. Das Für und Wider wird hier anhand einiger Grenzfälle diskutiert.

Eine abgeleitete Klasse kann als Subtyp der Oberklasse aufgefasst werden. Ein Objekt einer abgeleiteten Klasse kann damit stets an die Stelle eines Objekts der Oberklasse treten (Liskovsches Substitutionsprinzip [Lis]) – es sind ja alle Methoden der Oberklasse vorhanden, wenn auch möglicherweise überschrieben. Dies erscheint auf den ersten Blick einleuchtend. Dennoch gibt es Fälle, in denen dieser Satz der Konvention oder der menschlichen Erfahrung widerspricht. Ein einfaches Beispiel soll dies erläutern.

Seit Euklid, also seit mehr als 2000 Jahren, ist bekannt, dass ein Quadrat ein Rechteck und ein Kreis eine Ellipse ist. Genauer formuliert, ist ein Quadrat ein Rechteck mit gleichen Seitenlängen, also ein Spezialfall eines Rechtecks. Die Spezialisierung wird in C++ durch public-Vererbung ausgedrückt:

class Quadrat : public Rechteck { ... };

Stellen Sie sich nun aber eine Klasse Rechteck vor, die es erlaubt, die Seiten ungleichmäßig zu ändern; denken Sie etwa an einen grafischen Editor, mit dem ein Rechteck in verschiedene Richtungen auseinandergezogen werden kann:

Vordergründig ist klar, dass diese Methoden in einer Klasse Quadrat nichts zu suchen haben, wenn die Forderung aufrechterhalten bleiben soll, dass ein Quadrat-Objekt stets an die Stelle eines Rechteck-Objekts treten kann. Die manchmal empfohlene »Lösung«, dass zwischen Quadrat und Rechteck gar keine Vererbungsbeziehung besteht und beide Klassen von einer abstrakten Klasse Viereck erben sollten, ist nicht sinnvoll, weil das Problem nur auf eine andere Ebene verschoben wird: Ein allgemeines Viereck kann man diagonal zu einer Raute verformen, ein Rechteck nicht, wenn es eines bleiben soll. Um solche Fälle vernünftig darstellen zu können, wird Vererbung gelegentlich benutzt, um Einschränkungen (englisch constraints) einer Oberklasse zu formulieren (inheritance for restriction, siehe folgendes Beispiel). Dennoch ist sorgfältig zu überlegen, ob es nicht andere Wege gibt.

Ein großer Vorteil der Objektorientierung besteht darin, dass die Begriffe der Anwendung weit mehr als in nicht-objektorientierten Programmiersprachen durchgängig von der Analyse bis zum Code benutzbar sind. Davon soll man nicht ohne schwerwiegenden Grund abweichen – den es durchaus geben kann. Die Beziehung »ein Quadrat ist ein Rechteck« ist die natürliche Beziehung in einer mathematisch-geometrischen Anwendung, die beibehalten werden sollte. Nur vom Standpunkt der Implementierung her ist zu überlegen, ob der zusätzliche Aufwand in Kauf genommen werden soll, Platz für zwei Seitenlängen zu spendieren, obwohl nur eine nötig ist.

In der objektorientierten Programmierung geht es unter anderem um einen Vertrag mit dem Benutzer einer Klasse. Der Benutzer muss sich darauf verlassen können, dass die Klasse den Vertrag einhält, das heißt, dass die Seitenlängen im Quadrat untereinander stets gleich bleiben.

Wenn die Klasse Quadrat von der Klasse Rechteck erben soll, lässt sich das Einhalten der Bedingung gleicher Seitenlängen leicht bewerkstelligen:

Die vertragliche Einschränkung, dass Höhe und Breite eines Quadrats stets gleich sind, wird an alle von Quadrat abgeleiteten Klassen vererbt. Eine Möglichkeit, ohne Vererbung auszukommen und ohne auf die Funktionen eines Rechtecks zu verzichten, soweit sie angemessen sind, zeigt das folgende Beispiel, in dem ein Quadrat ein Rechteck benutzt:

Die Methoden des Rechtecks sind für Quadratbenutzer nicht mehr zugreifbar, aber die Klasse Quadrat macht sich die Methoden zunutze, indem es die Aufgaben an das Rechteck r delegiert. Der Nachteil dieser Lösung besteht darin, dass Quadrat und Rechteck nicht weiterhin polymorph benutzbar sind. Wenn Quadrat von der Klasse GraphObj erben würde, hätte man das Problem, dass der Bezugspunkt doppelt angelegt wäre: im anonymen Subobjekt und im privaten Rechteck-Objekt. Falls Quadrat nur wenige Funktionen von Rechteck benutzt, der Aspekt der gemeinsamen Schnittstelle also keine große Rolle spielt, ist es besser, Quadrat als eigenständige Klasse zu implementieren, die von GraphObj erbt.

Problemstellungen dieser Art kommen gelegentlich vor. Ein weiteres Beispiel: Eine sortierte Liste ist doch sicherlich auch eine Liste – oder? Bei näherer Betrachtung ist zu sehen, dass die sortierte Reihenfolge zerstört werden kann. Die Operation, ein beliebiges Element am Anfang einer Liste einzufügen, darf nicht für eine sortierte Liste gelten.

Die Ursache für das Dilemma liegt im Verständnis des Begriffs Spezialisierung bzw. der Ist-ein-Relation. Mit der public-Vererbung ist stets eine Spezialisierung der Schnittstellen oder eine Erweiterung gemeint, in der Mathematik oder in der Umgangssprache kann es aber auch eine Einschränkung oder Verminderung der Schnittstellen bedeuten.

Nur wenn ein Objekt einer abgeleiteten Klasse jederzeit an die Stelle eines Basisklassenobjekts treten kann, ist die public-Vererbung sinnvoll, und nur dann kann der Typ der abgeleiteten Klasse als Subtyp der Basisklasse aufgefasst werden. Andernfalls ist die umgangssprachlich in der Modellierung benutzte Ist-ein-Beziehung auf andere Art darzustellen. Damit kann Quadrat zwar von der oben beschriebenen Klasse Rechteck erben. Dies würde jedoch nicht mehr gelten, wenn die Klasse Rechteck eine weitere Methode seitenverhaeltnisAendern() hätte, weil sie vom Quadrat nicht ohne Verletzung des Vertrags realisiert werden kann. Die erwähnte sortierte Liste soll nicht public von einer Listenklasse erben.

6.7 Mehrfachvererbung2

Die Mehrfachvererbung gewährt eine große Flexibilität insbesondere bei der Systemmodellierung, wird jedoch nicht häufig benötigt – je nach Art der Problemstellung. Die Mehrfachvererbung bietet manchmal gegenüber der Einfachvererbung bessere Möglichkeiten, Objekte der realen Welt abzubilden.

Eine Klasse kann von mehreren Basisklassen erben. Da hier nur das Prinzip der Mehrfachvererbung gezeigt werden soll, betrachten wir im Folgenden ein möglichst einfaches Beispiel, das als C++-Programm ausformuliert wird. Auf einem Grafikbildschirm sollen verschiedene Objekte dargestellt werden, hier ein Rechteck (Rechteck) und ein beschriftetes Rechteck (beschriftetesRechteck).

Ein beschriftetes Rechteck ist ein beschriftetes grafisches Objekt, und ein beschriftetes grafisches Objekt wiederum ist ein grafisches Objekt. Dieser Zusammenhang wird durch die in Abbildung 6.2 dargestellte Vererbungsstruktur gezeigt.

Abbildung 6.2: Vererbungsstruktur grafischer Objekte

Die Klasse BeschriftetesObjekt ist wie GraphObj abstrakt, weil die in der letzteren Klasse deklarierte rein virtuelle Funktion flaeche() nicht in BeschriftetesObjekt definiert ist und daher die Eigenschaft »abstrakt« geerbt wird. Mehrfachvererbung gibt es auch in der C++-Standardbibliothek, wie die Abbildungen auf Seite 424 zeigen.

Es ist im Allgemeinen nicht notwendig, dass von einer gemeinsamen Basisklasse geerbt wird. Hier wurde das Beispiel absichtlich so gewählt, weil mit einer gemeinsamen Basisklasse eine spezielle Problematik auftritt, die in Abschnitt 6.7 besprochen wird. Alle grafischen Objekte haben bestimmte gemeinsame Eigenschaften. Zum Beispiel hat jedes Objekt einen bestimmten Ort auf dem Bildschirm, nämlich den Bezugspunkt referenzkoordinaten. Es folgen die Header-Dateien *.h mit den Deklarationen für BeschriftetesObjekt und BeschriftetesRechteck. Die anderen Deklarationen sind in Listing 6.15 ab Seite 321 zu finden.

Die Klasse BeschriftetesObjekt enthält ein Objekt beschriftung des Typs string. Der Einfachheit halber sind alle Methoden inline. Die Klasse BeschriftetesObjekt benötigt keinen Destruktor, weil der systemerzeugte Destruktor die Destruktoren für alle Elemente einer Klasse aufruft.

Die zur tatsächlichen Ausgabe auf dem Bildschirm notwendigen Grafikfunktionen sind systemspezifisch, sodass hier nur eine schlichte Textausgabe auf dem Bildschirm erscheinen soll. Der Konstruktor ruft jeweils den Basisklassenkonstruktor zur Initialisierung auf.

Auch ein BeschriftetesRechteck wird mit den Oberklassenkonstruktoren initialisiert, die ihrerseits den Basisklassenkonstruktor aufrufen. Die Funktion zeichnen() ruft die entsprechenden Methoden der Subobjekte auf.

In einem Hauptprogramm könnten dann Anweisungen folgender Art stehen:

Das Objekt *zBR muss mit delete gelöscht werden, weil es mit new erzeugt wurde. Die Anwendung von delete auf einen Zeiger ruft automatisch den Destruktor des referenzierten Objekts auf.

Namenskonflikte

Bei Mehrfachvererbung können Namenskonflikte und Mehrdeutigkeiten auftreten. Zum Beispiel könnte man versuchen, sich die Koordinaten der Objekte ausgeben zu lassen:

std::cout << "Rechteck-Position:˽"; anzeigen(r.bezugspunkt()); std::cout << "beschriftetes-Rechteck-Position:˽"; anzeigen(bR.bezugspunkt()); // Compiler-Fehlermeldung!

Vom Rechteck r würde der Bezugspunkt ausgegeben werden, die Ausgabe der Koordinaten des beschrifteten Rechtecks bR führt hingegen zu einer Fehlermeldung des Compilers. Warum? Der Aufruf ist zweideutig. Die Ursache liegt darin, dass GraphObj zweimal geerbt wurde. Der Compiler weiß nicht, ob er den Bezug zu GraphObj::bezugspunkt() über das in BeschriftetesObjekt oder das in Rechteck enthaltene Subobjekt vom Basisklassentyp GraphObj konstruieren soll. Durch die Angabe der Basisklasse wird die Zweideutigkeit beseitigt:

anzeigen(bR.Rechteck::bezugspunkt()); // eindeutig

Ferner wird durch verschiedene Bezugspunkte im Konstruktor nachgewiesen, dass BeschriftetesRechteck zwei GraphObj-Objekte besitzt:

// absichtlich veränderter Konstruktor BeschriftetesRechteck(const Ort& ort, int h, int b, const std::string& b) : BeschriftetesObjekt(ort, b), Rechteck(Ort(100, 100), h, b) // verschiedene Koordinaten! { }

Die Anwendung ergäbe jetzt verschiedene Werte:

anzeigen(bR.Rechteck::bezugspunkt()); anzeigen(bR.BeschriftetesObjekt::bezugspunkt());

Weil zwei Subobjekte vom Typ GraphObj vorliegen, ist wegen der Nicht-Eindeutigkeit die Zuweisung eines Zeigers nicht möglich. Deswegen kann im folgenden Listing &bR2 nicht in den Vektor aufgenommen werden.

Auf welches Subobjekt soll der Zeiger zeigerAufGrafischeObjekte[2] in Listing 6.30 verweisen? Die tatsächliche Objekthierarchie für ein BeschriftetesRechteck-Objekt bR ergibt sich aus der Abbildung 6.3, wobei die Verbindungen hier eine enthält-Beziehung symbolisieren, das heißt, das beschriftetesRechteck bR enthält ein Rechteck- und ein beschriftetesObjekt-Subobjekt, die beide je ein GraphObj-Subobjekt enthalten, deren Koordinaten nicht notwendigerweise gleich sein müssen. Im nächsten Abschnitt wird gezeigt, wie die Zweideutigkeiten aufgelöst werden.

Virtuelle Basisklassen

Wenn bei Mehrfachvererbung nicht erwünscht ist, dass mehrere Basisklassensubobjekte erzeugt werden, können virtuelle Basisklassen verwendet werden. Von diesen Basisklassen wird nur ein Subobjekt erzeugt, auf das über verschiedene Vererbungswege zugegriffen werden kann. Die Mehrdeutigkeit im obigen Beispiel wäre dadurch aufgehoben. Im Folgenden werden nur die Deklarationen und die Methoden aus dem vorherigen Abschnitt ganz oder teilweise aufgelistet, die notwendige Änderungen enthalten.

Abbildung 6.3: Zweideutig: enthält-Beziehungen bei nicht-virtueller Vererbung

Mit diesen Änderungen sind Aufrufe wie

cout << "BeschriftetesRechteck-Position:˽"; anzeigen(bR.bezugspunkt());

möglich und unproblematisch, weil nun genau ein Basisklassensubobjekt für bR existiert. Anstelle der Subobjekte Subobjekt1:GraphObj und Subobjekt2:GraphObj aus Abbildung 6.3 gibt es jetzt nur noch ein Subobjekt auf der Ebene der Basisklasse, sodass die Subobjekt-Struktur der Abbildung 6.2 auf Seite 333 entspricht. Die Abbildung 6.2 ähnelt einem Rhombus, auch Raute genannt. Das englische Synonym für Rhombus ist diamond, weswegen das beschriebene Problem als diamond problem bekannt ist.

Der Konstruktor der Klasse BeschriftetesRechteck initialisiert jetzt das Basisklassensubobjekt; die Erklärung dafür finden Sie im folgenden Unterabschnitt »Virtuelle Basisklassen und Initialisierung«.

Weil nun genau ein Basisklassensubobjekt pro vollständigem Objekt existiert, kann ein Basisklassenzeiger auf ein Objekt der abgeleiteten Klasse gerichtet und damit der Polymorphismus ausgenutzt werden. Unter einem »vollständigen Objekt« wird ein Objekt verstanden, das nicht als Subobjekt dient, also nicht in einem anderen Objekt durch Vererbung enthalten ist. Im folgenden Beispiel sind R1, R2 und bR vollständige Objekte, nicht aber die in ihnen enthaltenen Subobjekte.

Virtuelle Basisklassen und Initialisierung

In Abschnitt 6.1 wird die Initialisierung von Subobjekten behandelt. Dabei werden Initialisierer in einer Liste angegeben, die noch vor dem Codeblock des Konstruktors abgearbeitet wird. In einer Klassenhierarchie kann es mehrere Initialisierer für eine Basisklasse geben. Falls wir jedoch virtuelle Basisklassen haben, wird nur ein Subobjekt dieser Basisklasse in Objekten einer abgeleiteten Klasse angelegt. Dann darf natürlich nur ein Initialisierer wirksam werden, damit es keine widersprüchlichen Ergebnisse gibt, wenn einer »Links!« und der andere »Rechts!« sagt. Um dieses Problem zu lösen, wird in C++ der Basisklasseninitialisierer genommen, der bei dem Konstruktor eines vollständigen Objekts angegeben ist, also einem Objekt, das bei der Definition in der Vererbungshierarchie ganz unten steht und das daher nicht als Subobjekt innerhalb eines anderen Objekts dient. Die anderen Basisklasseninitialisierer werden ignoriert. Wenn im Konstruktor kein Basisklasseninitialisierer aufgeführt ist, wird der Standardkonstruktor der virtuellen Basisklasse genommen. Das Programm zeigt die Initialisierung von Subobjekten virtueller Basisklassen. Es gibt zweimal Basis-Standardkonstruktor aus. Der Basisklasseninitialisierer Basis(a) in der Klasse Rechts wird beim Konstruktor von Unten ignoriert.

Stattdessen wird nur der beim Konstruktor von Unten direkt angegebene Basisklassenkonstruktor berücksichtigt. Da er hier auskommentiert ist, wird der Standardkonstruktor von Basis genommen. Wenn jedoch die Kommentarzeichen // aus den Initialisierungslisten entfernt werden, ist die Ausgabe

Unten Links.

Rechts::Basis(a) wird weiterhin ignoriert. Sie machen nichts falsch, wenn Sie sich bei virtueller Vererbung an die folgende Regel halten:

Image

Merke:

Bei virtueller Vererbung ist der Konstruktor eines vollständigen Objekts für die Initialisierung des Basisklassensubobjekts verantwortlich.

Image

Am einfachsten ist es daher, auf Attribute in der Basisklasse zu verzichten.

6.8 Typumwandlung bei Vererbung

Der static_cast-Operator

Die implizite Typumwandlung in einer Klassenhierarchie, die auf Seite 310 beschrieben wird, lässt sich ebenfalls invertieren, sodass zum Beispiel Wandlungen wie Basis* zu Abgeleitet* vorgenommen werden können:

GraphObj g(Ort(3, 17)); Strecke s(Ort(3, 17), (Ort(0, 0)); // Strecke ist von GraphObj abgeleitet GraphObj *pg; Strecke *ps {&s}; pg = ps; // bekannte implizite Konversion ps = pg; // verboten! ps = (Strecke*) pg; // gefährlicher C-Stil! ps = static_cast<Strecke*> (pg); // nur richtig, falls pg auf ein Strecke-Objekt zeigt

Der static_cast-Operator ist nur dann geeignet, wenn zur Compilierzeit bereits feststeht, dass der Basisklassenzeiger (pg) auf ein Objekt einer abgeleiteten Klasse zeigt. Anstelle von Zeigern sind Referenzen möglich. Die Typumwandlung von einer Basisklasse zur abgeleiteten Klasse wird downcast genannt. Die const-Eigenschaft von Objekten kann nicht mit dem static_cast eliminiert werden.

Der dynamic_cast-Operator

Der Operator dynamic_cast<T>(Ausdruck) setzt virtual-Vererbung voraus. Er wirkt ähnlich wie der static_cast-Operator, jedoch mit folgenden Unterschieden.

Image       Die Typprüfung findet zur Laufzeit statt, falls das Ergebnis nicht schon zur Compilierzeit bestimmt werden kann. Dann verhält sich dynamic_cast wie ein static_cast.

Image       Typ T muss ein Zeiger oder eine Referenz auf eine Klasse sein.

Image       Falls das Argument Ausdruck ein Zeiger ist, der nicht auf ein Objekt vom Typ T (oder abgeleitet von T) zeigt, wird als Ergebnis der Typumwandlung ein Null-Zeiger auf den Ergebnistyp, d.h. (T*)nullptr zurückgegeben.

Image       Falls das Argument Ausdruck eine Referenz ist, die nicht auf ein Objekt vom Typ T (oder abgeleitet von T) verweist, wird eine Ausnahme (Exception) vom Typ bad_cast ausgeworfen. Exceptions werden in Kapitel 7 behandelt.

Die wesentlichen Varianten sind in Listing 6.36 dargestellt:

Der Operator const_cast<T>(Obj) entfernt die const-Eigenschaft des Objekts Obj. Der Datentyp von Obj muss const T (oder T) sein, wobei T auch ein Zeiger oder eine Referenz sein kann. Der const_cast-Operator ersetzt die früher übliche Form (T) Obj, die eine erzwungene Typumwandlung eines beliebigen Datentyps nach T bewirkt. Die Typumwandlung soll nur in begründeten Ausnahmefällen vorgenommen werden, schließlich hat eine const-Deklaration ihren Sinn.

const int i {100}; const int *ip {&i}; *ip = 0; // geht nicht int *iq {const_cast<int*>(&i)}; // explizite Typumwandlung *iq = 0; // Wert von i wird geändert!

pointer_cast-Operatoren

Diese Operatoren dienen der Typumwandlung von shared_ptr-Zeigern. Zur dynamischen Typumwandlung eines Oberklassentyps benötigt man den dynamic_pointer_cast-Operator. Seine Anwendung wird in der folgenden Übungsaufgabe gezeigt. Der Vollständigkeit halber seien hier noch die Operatoren static_pointer_cast, const_pointer_cast und reinterpret_pointer_cast genannt.

Image

Übung

6.4 Lösen Sie die Aufgabe 6.3 auf Seite 328 mit dem dynamic_pointer_cast<>()-Operator. Geben Sie die Matrikelnummern aller Personen aus, sofern diese eine haben. Anwendung zum Beispiel:

for (auto personPtr : diePersonen) { auto studptr{dynamic_pointer_cast<StudentIn>(personPtr)}; // usw.

studptr ist ein shared_ptr auf ein StudentIn-Objekt. Wenn sich hinter personPtr kein StudentIn-Objekt verbirgt, ist studptr leer.

Image

6.9 Typinformationen zur Laufzeit

Der oben beschriebene dynamic_cast-Operator wandelt den Typ eines Objekts zur Laufzeit um und führt dabei gleichzeitig eine Prüfung durch. Meistens ist dies ausreichend, manchmal möchte man aber mehr wissen. Die Laufzeit-Typinformation kann für alle Methoden benutzt werden, die als Argument den Klassentyp selbst (das heißt auch Zeiger und Referenzen auf die Basisklasse) haben und polymorph benutzt werden sollen.

Typidentifizierung mit typeid()

Das Ergebnis eines typeid()-Ausdrucks ist vom Typ type_info&. Wenn das Argument von typeid() ein polymorpher Typ ist, bezieht sich das Ergebnis von typeid() auf das zugehörige vollständige Objekt. Mit »polymorpher Typ« ist gemeint, dass das Argument eine Referenz vom Basisklassentyp ist, die auf ein Objekt einer abgeleiteten Klasse verweist, wobei die Basisklasse mindestens eine virtual-Funktion haben muss. Die Dereferenzierung eines Zeigers durch ein vorangestelltes * liefert ebenfalls eine Referenz:

Im Programm wird nacheinander true und false ausgegeben, bevor in der letzten Anweisung eine bad_typeid-Ausnahme ausgeworfen wird, weil pNull ein Null-Zeiger ist. Der Vergleichsoperator vergleicht die von typeid() zurückgegebenen type_info-Objekte. Anstelle eines Objekts kann der Klassenname verwendet werden, die beiden Ausdrücke (typeid(Objekt2) == typeid(*p)) und (typeid(Abgeleitet) == typeid(*p)) haben dasselbe Ergebnis. Der Typ eines Objekts (Klassenname) kann mit typeid(Objekt1).name() als compilerabhängiger Wert vom Typ const char* erhalten werden.

Image

Übung

6.5 Lösen Sie die Aufgabe 6.3 auf Seite 328 mit dem typeid()-Operator. Geben Sie die Matrikelnummern aller Personen aus, sofern diese eine haben. Sie können nach dem Typvergleich wie in der vorhergehenden Aufgabe dynamic_pointer_cast benutzen. Alternativ ist auto studptr{static_cast<StudentIn*>(personPtr.get())}; möglich. Die Funktion get() gibt den im shared_ptr gekapselten rohen Zeiger zurück.

Image

6.10 Private-/Protected-Vererbung3

Delegation ist eine Möglichkeit zur Wiederverwendung von Code, private Vererbung, auch Implementationsvererbung genannt, ist eine andere. Die Delegation ist vorzuziehen, um mit der Vererbung ausschließlich eine Ist-ein-Beziehung zwischen Klassen abzubilden (Vererbung der Schnittstellen). Aber Sie sollten wenigstens wissen, was private Vererbung bedeutet, wenn auf der nächsten Party die Rede davon ist, um elegant zu einem interessanteren Thema wechseln zu können. Die private Vererbung wird hier am Beispiel einer Warteschlange oder Queue gezeigt (ansonsten benutzen Sie lieber die Klasse std::queue der Standardbibliothek). Dabei machen wir uns die Eigenschaften der Klasse std::list, einer doppelt verketteten Liste (siehe auch Seite 864), zunutze. Eine einfache Anwendung könnte wie folgt aussehen:

Bei privater Vererbung dürfen öffentliche Methoden der Oberklasse zwar innerhalb der Unterklasse benutzt werden, nicht aber von Objekten der Unterklasse. Es wird nicht mehr die Schnittstelle geerbt, sondern die Implementierung. Sollen einzelne Methoden für Objekte abgeleiteter Klassen nutzbar sein, also für Objekte der Klasse Warteschlange, sind sie durch eine Benutzungsdeklaration (englisch using declaration) zu kennzeichnen, wie in Listing 6.39 zu sehen. Die Benutzungsdeklaration besteht nur aus dem Schlüsselwort using und dem Namen der Funktion einschließlich der Klassenbezeichnung, aber ohne Parameterliste und Rückgabetyp. Auf diese Art wird der von der std::list-Klasse vererbte Methodenumfang der Oberklasse ausgewählt.

Ein privater Teil ist überflüssig, das verborgene Oberklassensubobjekt vom Typ std::list erledigt alles. Die Methoden push() und pop() existieren nicht in der Oberklasse und können deshalb nicht per using-Deklaration öffentlich gemacht werden. Konstruktor, Destruktor und Zuweisungsoperator sind nicht notwendig. Zum Vergleich sei hier ein Template gezeigt, in dem die Delegation an die Stelle der privaten Vererbung tritt:

Das Prinzip ist einfach: Ein Listenobjekt liste wird privat angelegt und die Elementfunktionen der Klasse Warteschlange rufen die öffentlichen Elementfunktionen des Objekts liste auf. Die Klasse Warteschlange delegiert damit Aufgaben an die Klasse std::list, weswegen das Prinzip Delegation genannt wird. Bisher sind wir davon ausgegangen, dass man für dynamische Datenstrukturen einen besonderen Kopierkonstruktor benötigt, wie am Beispiel der »flachen« und »tiefen« Kopie auf Seite 255 gezeigt. Wenn ein besonderer Konstruktor notwendig ist, gilt dies meistens auch für einen Destruktor und einen Zuweisungsoperator. Das alles können wir hier vergessen! Durch die Delegation enthält jedes Warteschlange-Objekt ein Objekt vom Typ std::list und nur dieses enthält eine dynamische Struktur. Weil bei der Kopie oder Zuweisung ein Objekt elementweise kopiert wird, wird also das einzige Element der Klasse Warteschlange kopiert (das private Objekt liste). Die Klasse std::list stellt alle Dienstleistungen bereit, sodass sie nicht besonders programmiert werden müssen. Die Klasse Warteschlange wird dadurch zu einem »Datentyp erster Klasse«, der so einfach wie die Grunddatentypen zu handhaben ist.

protected-Vererbung

Die protected-Vererbung spielt nur gelegentlich eine Rolle. protected-Methoden und -Daten sind von abgeleiteten Klassen nutzbar, aber nicht von außerhalb. Die protected-Vererbung kommt zum Beispiel im Design-Muster »Template Method« zur Geltung. Ein Beispiel kennen Sie von Listing 6.22 auf Seite 328.


1 Ist ein Quadrat ein Rechteck im Sinn der objektorientierten Programmierung? Dazu siehe Seite 330.

2 Dieser Abschnitt kann beim ersten Lesen übersprungen werden.

3 Dieser Abschnitt kann beim ersten Lesen übersprungen werden.