Vererbung

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;

Zugriffsspezifizierer

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();

Polymorphie

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 nicht

Wenn 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.

Abstrakte Klassen

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.

Freundschaften

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.

Überladen

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.

Methoden überladen

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

Operatoren überladen

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&);

Anmerkungen:

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 Zuweisung
// 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; 
}

Hinweis zur Operatorüberladung in Jupyter Notebooks

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.