Die Vererbung (engl.: Inheritance) ist ein Mechanismus, der es erlaubt, Datenstrukturen und Methoden einer Klasse (Basisklasse) bei der Bildung einer neuen Klasse (Unterklasse, abgeleitete Klasse) wiederzuverwenden. In der Unterklasse kann die Datenstruktur erweitert werden und/oder die Methoden erweitert oder modifiziert werden. Weiterhin stellt die Vererbung ein Hilfsmittel zur Strukturierung von Anwendungsproblemen dar.
Als Beispiel sei hier die Klasse Train genannt, die als Basisklasse für die Unterklasse PassengerTrain dient:
// Beispiel
class PassengerTrain: public Train;Mit der obigen Syntax wird eine Klasse deklariert, die alle
Datenelemente und Methoden einer bereits vorhandenen Klasse erbt. Der
Zugriffsspezifizierer gibt an, wie Datenelemente und Methoden der
Basisklasse innerhalb der abgeleiteten Klasse maximal zugänglich sind.
Ein öffentliches Datenelement der Basisklasse ist bei
public-Vererbung auch in der abgeleitete Klasse öffentlich,
private Elemente bleiben private. Mit
private-Vererbung kann der Zugriff auch für Datenelemente
eingeschränkt werden, die in der Basis öffentlich sind. Dadurch ist es
möglich, eine Vererbung komplett vom Anwender der Klasse zu
verstecken.
In vielen Fällen will man auf Datenelemente von Basisklassen auch in
abgeleiteten Klassen zugreifen. Dafür gibt es einen dritten
Zugriffsspezifizierer: protected. Genau wie auf
private-Mitglieder einer Klasse kann auch auf
protected-Mitglieder nicht von außerhalb der Klasse
zugegriffen werden, jedoch werden protected-Mitglieder an
eine abgeleitete Klasse vererbt, private-Mitglieder
nicht.
| Element in Basis | Vererbung | Element in abgeleiteter Klasse |
|---|---|---|
private |
public |
Zugriff nicht möglich |
protected |
public |
protected |
public |
public |
public |
| - | - | - |
private |
private |
Zugriff nicht möglich |
protected |
private |
private |
public |
private |
private |
Tabelle 3.1: Zugriffsmöglichkeiten auf Basisklassenelemente
Tabelle 3.1 gibt einen Überblick über die Zugriffsmöglichkeiten auf
Elemente von Basisklassen. In der Regel verwendet man für die Vererbung
public, da dann der Zugriff auf Mitglieder der abgeleiteten
Klasse in gleicher Weise möglich ist wie der Zugriff auf Mitglieder der
Basisklasse. Falls der Zugriffsspezifizierer weggelassen wird, ist die
Vererbung private.
Bei der Konstruktion von Objekten wird zunächst der Konstruktor der Basisklasse aufgerufen, danach der Konstruktor der abgeleiteten Klasse. Um zu steuern, welcher Konstruktor der Basisklasse benutzt werden soll, kann als erstes in einer Initialisierungsliste der gewünschte Konstruktor der Basisklasse angegeben werden. Wird dort kein Konstruktor angegeben, wird der Standardkonstruktor der Basisklasse aufgerufen. Das folgende Beispiel zeigt die Verwendung einer Klassenhierarchie anhand von einfachen grafischen Elementen:
#include <iostream>
class Shape
{
protected:
double p_x = 0.0;
double p_y = 0.0;
public:
Shape () = default;
Shape(double x, double y) : p_x(x), p_y(y) {}
double area () const {
return 0.0;
}
};
class Box : public Shape // Box abgeleitet von Shape
{
double p_w = 0.0;
double p_h = 0.0;
public:
Box() = default;
Box(double x, double y, double w, double h)
: Shape(x, y), // Aufruf des Konstruktors der Basisklasse
p_w(w),
p_h(h)
{}
double area () const
{
return p_w * p_h;
}
};
class Line : public Shape {
double p_end_x = 0.0;
double p_end_y = 0.0;
public:
Line() = default;
Line(double x, double y, double end_x, double end_y)
: Shape(x, y), // Aufruf des Konstruktors der Basisklasse
p_end_x(end_x),
p_end_y(end_y)
{}
};
// double area() const geerbt von Shape
int main () {
Box box(10.0, 10.0, 6.0, 7.0);
Line line(10.0, 10.0, 30.0, 30.0);
// Fläche berechnen
std::cout << "Box area:\t" << box.area() << std::endl;
std::cout << "Line area:\t" << line.area() << std::endl; // 0.0, geerbt
};
main();
Viele Klassen lassen sich auf die gleiche oder ähnliche Weise
benutzen. Im oben gezeigten Beispiel der Shape-Klasse würde die Methode
area von mehreren Klassen geerbt werden, wobei die Implementierung für
jede Klasse unterschiedlich, die Schnittstelle aber gleich ist. Damit
alle Objekte, die die Schnittstelle implementieren, über eine Referenz
als Shape oder einen Pointer auf Shape korrekt angesprochen werden, muss
die Funktion in der Basisklasse als virtuell (Schlüsselwort
virtual) deklariert werden. Wenn area() über Referenz oder
Pointer der Basisklasse aufgerufen wird, wird bei einer virtuellen
Funktion zunächst in der Unterklasse überprüft, ob eine Implementierung
für diese Funktion vorliegt. Dann wird die richtige Implementierung für
die area-Methode ausgewählt. Man spricht dann von Polymorphie
(Vielgestaltigkeit). Wird die Funktion in der Basisklasse nicht virtuell
definiert, wird sonst bei einem Zeiger oder einer Referenz der
Basisklasse direkt die Funktion der Basisklasse aufgerufen. Man spricht
hier auch von dynamischer bzw. später Bindung (engl.: late binding).
Beispiel: (identisch bis auf die Definition von Shape::area() als virtual)
namespace x {
class Shape
{
protected:
double p_x = 0.0;
double p_y = 0.0;
public:
Shape () = default;
Shape(double x, double y) : p_x(x), p_y(y) {}
virtual double area () const { // <-------Deklaration als virtuelle Methode
return 0.0;
}
};
class Box : public Shape // Box abgeleitet von Shape
{
double p_w = 0.0;
double p_h = 0.0;
public:
Box() = default;
Box(double x, double y, double w, double h)
: Shape(x, y), // Aufruf des Konstruktors der Basisklasse
p_w(w),
p_h(h)
{}
double area () const
{
return p_w * p_h;
}
};
class Line : public Shape {
double p_end_x = 0.0;
double p_end_y = 0.0;
public:
Line() = default;
Line(double x, double y, double end_x, double end_y)
: Shape(x, y), // Aufruf des Konstruktors der Basisklasse
p_end_x(end_x),
p_end_y(end_y)
{}
};}
Wenn dann Objekte als Referenz oder Pointer von Shape
verwendet werden, werden die Methoden der Unterklasse aufgerufen, sofern
sie existieren. Wenn sie in der abgeleiteten Klasse nicht überschrieben
wird, wird die Methode aus der Basisklasse aufgerufen.
int main()
{
x::Line line (10.0 , 10.0 , 30.0 , 30.0);
x::Shape* shape1 = new x::Box (10.0 , 10.0 , 20.0 , 20.0);
std::cout << shape1 ->area () << std::endl; // ruft Box:: area auf
x::Shape& shape_ref1 = *shape1;
std::cout << shape_ref1.area() << std::endl; // ruft Box::area auf
x::Shape& shape_ref2 = line;
std::cout << shape_ref2.area() << std::endl; // ruft Shape::area auf , da Line::area nicht existiert
}
main();
Würde hier das virtual in Shape weglassen
werden, würde immer die Funktion aus der Basisklasse
(Shape) aufgerufen. Sinnvoll ist das Speichern von Pointern
der Basisklasse besonders bei gemeinsamen Feldern oder Containern. Die
Überschreibung von Methoden geht nur bei gleichen Signaturen. Die
Signatur einer Methode besteht aus dem Namen, Anzahl und Datentyp der
Argumente sowie const. Häufig wird versucht irrtümlich,
Methoden, die in der Basis const sind, mit
nicht-const-Methoden zu überschreiben oder umgekehrt:
class Base
{
public:
virtual void method() const;
};
class Derived : public Base {
public:
virtual void method(); // hier fehlt const
};
int main() {
Derived d;
Base& base = d;
base.method(); // ruft Base::method auf, da die const Funktion nicht überschrieben wird
}Da in Derived das const fehlt, hat Derived
zwei Methoden, die method heißen:
void method() und void method() const (von
Base geerbt). Da das in den allermeisten Fällen nicht beabsichtigt ist,
sollten alle überschreibenden Methoden mit dem Schlüsselwort
override gekennzeichnet werden. Der Compiler gibt dann
Fehler aus, wenn keine passende Methode in der Basisklasse vorhanden
ist.
void method() override; // Fehler: Markiert als override, aber überschreibt nichtWenn die oberste Methode virtual ist, sollten alle
überschreibenden Methoden auch virtual sein. Angemerkt sei
noch, dass Destruktoren immer virtual erklärt werden
sollten. Ein virtueller Destruktor sorgt dafür, dass der Destruktor
einer abgeleiteten Klassen aufgerufen wird, auch wenn ein Pointer
verwendet wird, der vom Typ der Basisklasse ist. So wird sichergestellt,
dass alle belegten Ressourcen freigegeben werden.
Konstruktoren dürfen nicht virtuell erklärt werden. Wenn ein Konstruktor virtuell wäre, würde die Auswahl der Implementierung vom Datentyp abhängen. Da zur Konstruktionszeit das Objekt noch nicht existiert, kann diese Auswahl nicht getroffen werden. Außerdem macht es keinen Sinn, ein Objekt mit unbekanntem Datentyp zu erstellen.
Da die späte Bindung zur Laufzeit erfolgt, ist sie etwas langsamer
als statische Aufrufe. Daher ist es nicht sinnvoll, alle Methoden einer
Klasse ohne Grund virtual zu definieren.
Zum Aufbau von Klassenstrukturen ist es oft sinnvoll, gemeinsame
Eigenschaften mehrerer Klassen in einer Oberklasse zusammenzufassen,
obwohl eigentlich keine Objekte dieser Oberklasse existieren sollen. So
könnte z.B. eine Basisklasse Zug die gemeinsamen
Eigenschaften von Personenzug und Güterzug
enthalten. Allerdings wäre es nicht sinnvoll, allgemeine Züge zu
erstellen, da die Objekte in diesem Beispiel entweder der einen oder der
anderen Kategorie angehören sollen. Analog sollten sich auch keine
Objekte der Klasse Shape erstellen lassen. In C++ realisiert man eine
abstrakte Klasse durch Deklaration mindestens einer
„rein-virtuellen“ Methode. Eine rein-virtuelle Methode
wird bei der Deklaration mit = 0 markiert. Die Klasse heißt dann
„abstrakt“. Es können keine Objekte einer
abstrakten Klasse erzeugt werden. Es ergibt sich folgende Syntax für
eine solche Methode:
virtual return-type function-name (parameter-list ) = 0;
Zusätzlich können auch rein-virtuelle Methoden mit const
markiert werden. Eine rein-virtuelle Methode braucht in der Basisklasse
nicht implementiert werden. Eine zusätzliche Implementierung ist aber
möglich. In allen abgeleiteten Klassen müssen rein-virtuelle
überschrieben werden. Mit rein-virtuellen Funktionen gibt man so ein
Implementierungsinterface für die Unterklassen vor. Wie bei allen
Klassen mit virtuellen Methoden, sollte auch bei einer abstrakten Klasse
ein virtueller Destruktor definiert werden. Wenn man keine eigene
Funktion als rein virtuell definieren will, kann man daher den Destruktor
benutzen, um eine Klasse als abstrakt zu kennzeichnen. Ansonsten kann
man ihn mit =default definieren.
Ein grundlegendes Konzept von C++ ist die Datenkapselung, d.h. nur
die Methoden einer Klasse besitzen Zugriff auf die privaten Mitglieder.
Hierdurch kann das Objekt selbst kontrollieren, welche Werte in seine
Instanzvariablen geschrieben werden und Fehleingaben abfangen. In
Ausnahmefällen kann es jedoch sinnvoll sein, dass auch Funktionen, die
nicht Mitglied der Klasse sind, Zugriff auf private Mitglieder erhalten.
Dies lässt sich durch friend-Funktionen erreichen. Eine als
friend deklarierte Funktion ist nicht Mitglied der Klasse,
hat aber Zugriff auf die privaten Mitglieder dieser Klasse.
Es sei betont, dass die Klasse selbst die „Freundschaft“anbieten
muss, d.h. sie muss der betreffenden Funktion den Zugriff auf ihre
Mitglieder erlauben. Da durch friend- Funktionen das
Konzept der Datenkapselung unerwünscht umgangen wird, sollte man diese
Funktionen sparsam einsetzen und durch entsprechende Zugriffsfunktionen
ersetzen. Das Beispiel zeigt die Verwendung von Freundschaften:
class Person
{
private:
int p_iAge;
public:
Person() = default;
Person(int age): p_iAge(age){};
int getAge() const {return p_iAge;}
//hier wird der Funktion "vBirthday" die Freundschaft angeboten
friend void vBirthday(Person&);
};
void vBirthday(Person &person) // globale Methode, nicht in Klasse
{
// Zugriff auf privates Mitglied von p
person.p_iAge++;
}
int main ()
{
Person hans = Person(42);
std::cout << hans.getAge() << std::endl;
vBirthday(hans);
std::cout << hans.getAge() << std::endl;
}
main();
Es sei noch erwähnt, dass eine Klasse nicht nur Funktionen, sondern auch ganzen Klassen die Freundschaft anbieten kann. Alle Mitgliedsfunktionen der befreundeten Klasse erhalten unbeschränkten Zugriff auf alle Mitglieder der eigenen Klasse.
In C++ können mehrere Funktionen mit gleichen Namen definiert werden. Diese Technik wird als Überladen (engl. overload) bezeichnet. Da Operatoren auch Funktionen sind, können sie auch überladen werden. Welche überladene Funktion aufgerufen wird, bestimmen Typ und Anzahl der Argumente. Das bedeutet, dass sich überladene Funktionen in der Anzahl oder dem Typ der übergebenen Argumente unterscheiden müssen. Bitte beachten Sie, dass ein veränderter Rückgabetyp einer Funktion kein Kriterium darstellt. Der Compiler meldet einen Fehler, wenn sich zwei Funktionen lediglich im Typ ihres Rückgabewertes unterscheiden.
Das folgende Beispiel zeigt eine überladene Funktion, die das Maximum zweier int-oder char-Werte liefert:
int max(int a, int b) { return (a > b) ? a : b; } //int comparison
char max(char a, char b) { return (a > b) ? a : b; } //char comparison
Bemerkung: Der Methodenrumpf ist hier identisch, da für Variablen vom Datentyp char, die intern als ASCII-Werte gespeichert werden, der Vergleichsoperator > überladen ist (dazu im nächsten Abschnitt mehr)
Der Versuch, die Funktion ein weiteres Mal zu überladen, so dass der größere zweier int-Werte ausgegeben wird, führt allerdings zu einem Fehler. Ansonsten wäre nämlich beim Aufruf dieser mehrfach überladenen Methode nicht entscheidbar, welche der beiden Implementierungen mit identischer Signatur hier verwendet werden soll.
void max(int a, int b)
{
if (a > b) std::cout << a << " is bigger than " << b << std:: endl;
else if (b > a) std::cout << b << " is bigger than " << a << std:: endl;
else std::cout << a << " equals " << b << std:: endl;
}
Der Versuch, die Funktion ein weiteres Mal zu überladen, so dass der größere zweier int-Werte ausgegeben wird, führt zu einem Fehler:
Konkret unterscheidet sich diese Funktion von
int max(int a, int b) nur im Typ des Rückgabewerts
(void statt int). Daher könnte der
Compiler beim Funktionsaufruf nicht entscheiden, welcher Code ausgeführt
werden soll.
Ähnlich der außerhalb einer Klasse definierten Funktionen können auch klasseneigene Methoden überladen werden. Den verschiedenen Versionen einer überladenen Methode können verschiedene Zugriffsrechte gegeben werden. Eine sehr häufig überladene Methode ist der Konstruktor. Folgendes Beispiel hat drei Konstruktoren: Default-Konstruktor, Copykonstruktor und einen parametrisierten Konstruktor.
class Studi
{
private:
int p_ID;
int p_Semester;
public:
Studi(); // Standard-Konstruktor
Studi(const Studi& aStud); // Copykonstruktor
Studi(int id, int semester); // param. Konstruktor
};
int main (){
Studi studi; // Standard-Konstruktor
Studi studi2 (studi); // Copykonstruktor
Studi arthur (123456, 42); // param. Konstruktor
}
Man beachte die Unterschiede zwischen den vorgestellten Möglichkeiten, Methoden mit gleichen Namen zu definieren:
| Bezeichnung | Parameter | Ort |
|---|---|---|
| 1. Vererbung | identisch | in Unterklasse, geerbt von Basisklasse |
| 2. Polymorphie | identisch | in verschiedenen Klassen |
| 3. Überladung | verschieden | in der gleichen Klasse |
In C++ sind Operatoren prinzipiell als Funktionen definiert und können somit wie Funktionen benutzt und auch überladen werden. Der Name einer Operatorfunktion besteht aus dem festen Bestandteil operator und seinem Zeichen. Der Funktionsname für den Operator + lautet also operator+ und die Anweisung int a = 4 + 3; wird vom Compiler in int a = operator+(4, 3); übersetzt.
Für die fundamentalen Datentypen wie z. B. int können
keine Operatoren überladen werden. Daher muss bei der Operatorüberladung
mindestens der 1. Operand ein Objekt sein. Operatoren auf komplexeren
Datenstrukturen können individuell definiert werden. Der Plus-Operator
zur Stringverkettung ist zum Beispiel eine Überladung. Bei den folgenden
Operatoren kann eine Überladung sinnvoll sein:
• Vergleich (==, !=, <, <=, > und >=)
• Arithmetisch (+, -, *, / und %)
• Index-Zugriff ([])
• Zuweisung (=, +=)
• Ein-/Ausgabe (<<, >>)
Es können auch noch weitere Operatoren überladen werden.
In C++ werden Klassenmethoden wie normale Funktionen behandelt, die
implizit als ersten Parameter den this-Zeiger der
zugehörigen Instanz bekommen. Das gilt auch für Operatoren. Ein
zweistelliger Operator kann durch eine Klassenmethode mit einem
Parameter oder durch eine globale Funktion mit zwei Parametern definiert
werden. Ein einstelliger Operator kann durch eine Klassenmethode ohne
Parameter oder durch eine globale Funktion mit einem Parameter definiert
werden.
Manchmal ist eine Definition innerhalb der Klasse unmöglich, z.B. wenn
der erste Parameter nicht ein Objekt der Klasse sein soll, da bei
Methoden der this-Zeiger der erste Parameter ist. Beispiele
sind die Ein- und Ausgabeoperatoren (<<, >>), bei denen der
erste Operand ein istream bzw. ostream ist, oder auch bei arithmetischen
Operatoren auf gemischten Typen, also z.B.
operator+(int, X).
Als Beispiel folgt die Deklaration der am häufigsten überladenen Operatoren für eine Klasse X:
class X
{
public:
// Inkrement:
const X operator ++( int); // Postfix -Inkrement
X& operator ++(); // Praefix -Inkrement
// Zuweisungen:
X& operator +=( const X&); // X+=X
// analog für andere Zuweisungen
// Andere:
X& operator []( int); // Subskript
};
// arithmetisch und logisch:
X& operator +( const X&, const X&)
// andere analog
//Vergleich
bool operator ==( const X&, const X&);
// analog für !=,<,>,<=,>=
// Ein - und Ausgabe
std::istream& operator >>( std::istream&, X&);
std::ostream& operator <<( std::ostream&, const X&);
Wie bei den meisten Methoden sollten konstante Referenzen übergeben werden, damit Objekte nicht beim Funktionsaufruf kopiert werden. Man kann sich die Übergabe konstanter Referenzen als ein schnelles call-by-value vorstellen. Schnell, weil nur eine Referenz übergeben wird, und call-by-value, weil auf den Wert nur lesend zugegriffen werden darf.
Die Zuweisungsoperatoren (=, +=, -=, ...) sollten immer eine nicht konstante Referenz auf ein Element der Klasse (normalerweise wird dies this sein) zurückgeben, da dadurch Zuweisungsketten wie a=(b=c); oder auch (a=b)=c; möglich sind. Beachten Sie den Unterschied zwischen =-Operator und Copykonstruktor: Beim Copykonstruktor wird ein neues Element erzeugt, beim =-Zuweisungsoperator sind beide Elemente bereits vorher erstellt worden. Beide Operatoren sind per default vorhanden und kopieren die Variablen byteweise.
MyClass x; // x wird per Standardkonstruktor erstellt
MyClass y(x); // y wird per Copykonstruktor auf der Basis von x erzeugt
x = y // y wird x zugewiesen (keine Erstellung)
MyClass y = x // Falle: identisch zu MyClass y(x); hier
//wird der Copykonstruktor aufgerufen , keine ZuweisungDamit der Operator zum Index-Zugriff (operator[])
auch auf der linken Seite einer Zuweisung vorkommen kann z.B. a[i] =
5;), sollte er eine Referenz zurückgeben. Weiterhin kann der
Zugriffsoperator anstatt int auch einen anderen Parameter
bekommen, z.B. ist bei std::map der Parameter ein
const string &.
Syntaktisch wird die Definition von Präfix bzw. Postfix durch den übergebenen `int unterschieden, er hat ansonsten keine weitere Bedeutung und wird meistens nicht benannt. Um den semantischen Unterschied zwischen den Präfix- und PostfixVarianten der Inkrement- bzw. Dekrement-Funktion zu verstehen, merkt man sich am besten folgende Regel. Präfix bedeutet „erhöhen und holen“, während Postfix „holen und erhöhen“bedeutet. Die Postfix-Variante kann keine Referenz zurückgeben, denn hier muss ein temporäres Objekt zurückgegeben werden, da ja der alte Wert zurückgeliefert werden muss, während der aktuelle Wert inkrementiert wird. Für Integer würden die beiden Inkrementversionen folgendermaßen aussehen:
// Präfix -Inkrement
MyInt& MyInt :: operator ++() // Referenz -Rückgabe
{
*this += 1;
return *this;
}
// Postfix -Inkrement
const MyInt MyInt :: operator ++(int) // Kopie -Rückgabe
{
MyInt oldValue = *this;
++(* this);
return oldValue;
}Die Postfix-Variante sollte einen konstanten Wert zurückliefern, da dies zum einen Standard in C++ ist und weiterhin Anweisungen wie i++; zurückgewiesen werden, da man ansonsten ein nichtintuitives Verhalten hat (Das zweite ++ erhöht nur eine temporäre Variable). Allerdings macht die Anweisung ++i; durchaus Sinn und ist durch die Referenzrückgabe auch möglich.
Operatoren, die außerhalb der Klasse deklariert werden und auf
Member zugreifen wollen, müssen ggf. als friend deklariert
werden oder eine Memberfunktion zum Zugriff benutzen.
Der Ausgabeoperator operator<<() sollte eine
Referenz auf ostream zurückliefern, damit Anweisungen wie
cout << a << b; möglich sind, das nämlich zu
(cout.operator<<(a)).operator<<(b); übersetzt
wird. Gleiches gilt für den Eingabeoperator.
Eine Entwicklungsumgebung sollte idealerweise keinen negativen Einfluss auf die Implementierung haben, was bisher bei Ihnen mit JupyterLab hoffentlich zutraf, insbesondere weil keine individuelle Softwareinstallation notwendig ist. Im Fall der Operatorüberladung besteht allerdings im Modul Xeus-Cling, welches wir zur Kompilierung der C++-Dateien verwenden (da JupyterLab alleine nur Python unterstützt) ein Bug, der folgenden Workaraound notwendig macht:
#define OPERATOR operator<<
std::ostream& OPERATOR (std::ostream & os, const Color& c)
{
os << c.r << ", " << c.g << ", " << c.b << std::endl;
return os;
}
#undef OPERATORÜbungsaufgaben zu diesem Kapitel finden Sie hier.