Vererbung



Oberbegriffe und Oberklassen

Vererbung: Beispiel Personen Jeder kennt die Oberbegriffe aus der natürlichen Sprache. Wenn wir von Fahrzeugen reden, dann können dies Autos, Lastwagen, Motorräder, Busse, Züge, Schiffe, Flugzeuge oder auch Fahrräder und Roller sein. Autos kann man auch wieder nach verschiedenen Kriterien klassifizieren, z.B. nach der Antriebsart, also Benziner, mit Diesel oder mit Strom angetriebene Autos. In der objektorientierten Programmierung gibt es etwas Ähnliches. Man spricht in der OOP von Vererbung und von Ober- und Unterklassen. Statt Oberklasse benutzt man auch die Synonyme Basisklasse, Elternklasse und Superklasse. Für Unterklasse gibt es auch die Synonyme abgeleitete Klasse, Kindklasse, und Subklasse.

Ein weiteres Beispiel eines Oberbegriffes stellt Person dar. Häufig werden auch Klassen modelliert, die Eigenschaften von Personen in Datenstrukturen abbilden. Allgemeine Daten für Personen sind Name, Vorname, Wohnort, Geburtsdatum, Geschlecht und so weiter. Wahrscheinlich würde man jedoch nicht Daten wie Personalnummer, Steuernummer, Religionszugehörigkeit oder Ähnliches aufnehmen. Nehmen wir nun an, dass man die allgemeine Personenklasse für die Firmenbuchhaltung benutzen will. Man merkt schnell, dass nun die Information über die Religionszugehörigkeit, die Steuerklasse oder Kinderzahl benötigt wird, um das Nettogehalt korrekt zu berechnen. Vor allen Dingen benötigt man firmenspezifische Daten für die Angestellten, wie beispielsweise Personalnummern und Abteilungszugehörigkeiten. Die Personendaten können wir aber auch beispielsweise für eine Klasse Clubmitglieder verwenden. Auch hier benötigen wir gegebenenfalls zusätzliche Informationen, z.B. Eintrittsdatum, Funktion im Verein und so weiter.

Ganz allgemein kann man sagen, dass die Vererbung eine Beziehung zwischen einer allgemeinen Klasse (einer Oberklasse oder einer Basisklasse) und einer spezialisierten Klasse (der Unterklasse, manchmal auch Subklasse genannt) definiert.

Ein einfaches Beispiel

Wir wollen nun die Personenklasse implementieren. Wir beschränken uns allerdings nur auf die Instanzattribute für den Vornamen (__vorname), Nachnamen (__nachname) und das Geburtsdatum (__geburtsdatum) für eine Person. Die Klasse Angestellter erbt von Person und wird um ein zusätzliches Instanzattribut Personalnummer (__personalnummer) ergänzt. Die Methode __str__ von Person wird in der Klasse Angestellter überschrieben. Allerdings benutzen wir in der Definition der Methode __str__ von Angestellter die Methode __str__ von Person. Der Aufruf kann entweder über super().__str__() oder über Person.__str__(self) erfolgen:

class Person:
    

    def __init__(self, vorname, nachname, geburtsdatum):
        self._vorname = vorname
        self._nachname = nachname
        self._geburtsdatum = geburtsdatum
        
    def __str__(self):

        ret = self._vorname + " " + self._nachname
        ret += ", " + self._geburtsdatum
        return  ret
        
class Angestellter(Person):
    

    def __init__(self, vorname, nachname, geburtsdatum, personalnummer):
        Person.__init__(self, vorname, nachname, geburtsdatum)
        # alternativ:
        #super().__init__(vorname, nachname, geburtsdatum)
        self.__personalnummer = personalnummer
        
    def __str__(self):
        #return super().__str__() + " " + self.__personalnummer
        return Person.__str__(self) + " " + self.__personalnummer
    
    
if __name__ == "__main__":
    x = Angestellter("Homer", "Simpson", "09.08.1969", "007")
    print(x)

Das obige Programm liefert die folgende Ausgabe:

Homer Simpson, 09.08.1969 007


Überladen und Überschreiben

Im vorigen Abschnitt hatten wir gesagt, dass die Methode __str__ in der Klasse Angestellter überschrieben wird ohne, dass wir auf den Begriff ,,Überschreiben'' näher eingegangen sind. Der Begriff Überschreiben (Englisch: override) beschreibt eine Technik in der objektorientierten Programmierung, die es einer abgeleiteten Klasse erlaubt, eine eigene Implementierung einer von der Basisklasse geerbten Methode zu definieren. Dabei ersetzt die überschreibende Methode der abgeleiteten Klasse die überschriebene Methode. Es ist auch möglich in der überschreibenden Methode die Methode, die man überschreibt, aufzurufen.

Im Zusammenhang von objektorientierter Programmierung haben Sie möglicherweise auch schon einmal etwas von ,,Überladen'' gehört. Überladen kennt man beispielsweise in C++ und Java. Um es gleich vorweg zu sagen: Es gibt kein Überladen von Funktionen und Methoden in Python. Es wird auch nicht benötigt, wie wir im Folgenden zeigen wollen.

Überladen von Methoden wird in statisch getypten Sprachen wie Java und C++ benötigt, um die gleiche Funktion mit verschiedenen Typen zu definieren.

Betrachten wir beispielsweise die Funktion nachfolger, die wir im folgenden Code definieren:

>>> def nachfolger(zahl):
...     return zahl + 1
... 
>>> nachfolger(1)
2
>>> nachfolger(1.6)
2.6
>>> nachfolger([3,5,9])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in nachfolger
TypeError: can only concatenate list (not "int") to list
>>> 

Der Parameter zahl hat, wie es in Python immer ist, keinen deklarierten Typ, d.h. wir können die Funktion mit beliebigen Typen aufrufen, auch wenn wir in den meisten Fällen einen Fehler erzeugen, wie oben im Listenfall. Fehlerfrei hat es jedoch mit einer Integer- und einer Float-Zahl als Argument funktioniert. In Java und C++ muss man dies als zwei separate Funktionen definieren, die zwar den gleichen Namen haben, aber unterschiedliche Parametertypen:

Der entsprechende Code sieht in C++ wie folgt aus:

#include <iostream>
#include <cstdlib>
 
using namespace std;
 
int nachfolger(int zahl) {
    return zahl + 1;
}

double nachfolger(double zahl) {
    return zahl + 1;
}

int main() {
    
    cout << nachfolger(10) << endl;
    cout << nachfolger(10.3) << endl;

    return 0;
}

Parameterüberladung gibt es in C++ und Java auch in der Form, dass die gleiche Funktion oder Methode mehrfach definiert wird, jedoch mit einer unterschiedlichen Zahl von Parametern.

Im folgenden C++-Programm definieren wir eine Funkton f, die ein oder zwei Integer-Parameter nehmen kann:

#include <iostream>
using namespace std;


int f(int n);
int f(int n, int m);

int main() {
    
    cout << "f(3): " << f(3) << endl;
    cout << "f(3, 4): " << f(3, 4) << endl;
    return 0;
}

int f(int n) {
    return n + 42;
}
int f(int n, int m) {
    return n + m + 42; 
}


Dies funktioniert nicht in Python, da eine weitere Funktionsdefinition mit dem gleichen Namen die erste überschreibt bzw. redefiniert. Wir können dies im folgenden Beispiel sehen:

>>> def f(n):
...     return n + 42
... 
>>> def f(n,m):
...     return n + m + 42
... 
>>> f(3,4)
49
>>> f(3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: f() takes exactly 2 arguments (1 given)
>>> 

In Python kann man obiges Verhalten mit Defaultparameter realisieren:

def f(n, m=None):
    if m:
        return n + m +42
    else:
        return n + 42

Alternativ lässt sich das auch mit einem *-Argument für eine beliebige Anzahl von Parametern realisieren:

def f(*x):
    if len(x) == 1:
        return x[0] + 42
    else: 
        return x[0] + x[1] + 42


Vererbung in Python

Einige Konzepte der Vererbung möchten wir zunächst an einer abstrakten Oberklasse mit abstrakten Unterklassen demonstrieren.

Wir definieren eine Klasse A mit einer __init__-Methode und einer Methode m:

class A:

    def __init__(self):
        self.content = "42"
        print("__init__ von A wurde ausgeführt")

    def m(self):
        print("m von A wurde aufgerufen")
    
class B(A):
    pass

if __name__ == "__main__":
    x = A()
    x.m()
    y = B()
    y.m()

Ruft man diese Klasse direkt auf, erhalten wir folgende Ausgabe:

$ python3 inheritance1.py 
__init__ von A wurde ausgeführt
m von A wurde aufgerufen
__init__ von A wurde ausgeführt
m von A wurde aufgerufen

Die Ergebnisse der ersten beiden Anweisungen hinter der if-Anweisung stellen nichts Neues dar. Aber wenn wir ein Objekt der Klasse B erzeugen, wie dies in der Anweisung ,,y = B()'' geschieht, dann können wir an der Ausgabe erkennen, dass die __init__-Methode der Klasse A ausgeführt wird, da B keine eigene __init__-Methode besitzt. Ebenso erkennen wir, dass beim Aufruf der Methode m für das Objekt y der Klasse B die Methode m von A aufgerufen wird, denn B hat keine Methode m.

Was passiert, wenn wir die Klasse B nun auch mit einer __init__-Methode und einer Methode m ausstatten? Wir testen dies im folgenden Beispiel. Die Klasse A und unsere Tests bleiben gleich, wir ändern lediglich die Klasse B wie folgt:

class B(A):
    def __init__(self):
        self.content = "43"
        print("__init__ von B wurde ausgeführt")

    def m(self):
        print("m von B wurde aufgerufen")

Rufen wir das veränderte Skript nun auf, erhalten wir die folgende Ausgabe:

$ python3 inheritance2.py 
__init__ von A wurde ausgeführt
m von A wurde aufgerufen
__init__ von B wurde ausgeführt
m von B wurde aufgerufen

Wir erkennen nun, dass die __init__-Methode von B und nicht mehr die von A beim Erzeugen einer Instanz von B aufgerufen wird. Außerdem wird nun die Methode m von B statt der Methode m von A benutzt. Man bezeichnet dies als Überschreiben (engl. overwriting) einer Methode. Man darf dies nicht mit Überladen verwechseln, was wir später behandeln werden.

Im folgenden Beispiel ändern wir nun die Instanzattribute der Klassen A und B. Wir haben in A ein Attribut self.contentA und in B eines mit dem Namen self.contentB.

class A(object):

    def __init__(self):
        self.contentA = "42"
        print("__init__ von A wurde ausgeführt")

    def m(self):
        print("m von A wurde aufgerufen")
    

class B(A):

    def __init__(self):
        self.contentB = "43"
        print("__init__ von B wurde ausgeführt")

    def m(self):
        print("m von B wurde aufgerufen")


if __name__ == "__main__":
    x = A()
    x.m()
    y = B()
    y.m()
    print("self.contentB von y: " + str(y.contentB))
    print("self.contentA von y: " + str(y.contentA))

Starten wir obiges Programm, sind eigentlich nur die beiden letzten Print-Anweisungen von Interesse.

$ python3 inheritance3.py 
__init__ von A wurde ausgeführt
m von A wurde aufgerufen
__init__ von B wurde ausgeführt
m von B wurde aufgerufen
self.contentB von y: 43
Traceback (most recent call last):
  File "inheritance3.py", line 25, in 
    print("self.contentA von y: " + str(y.contentA))
AttributeError: 'B' object has no attribute 'contentA'

Wie erwartet erhalten wir für self.contentB den Wert 43 als Ausgabe. Da die __init__-Methode der Klasse A nicht aufgerufen wird, sollte es uns nicht wundern, dass wir beim Versuch, self.contentA auszugeben, einen AttributeError mit dem Text ,B' object has no attribute ,contentA' erhalten, denn es gibt ja kein Attribut self.contentB in den Instanzen von B.

Wir können uns auch vorstellen, dass wir spezielle Attribute, die wir bei der Initialisierung eines Objektes einer Basisklasse einführen, auch in der Unterklasse verwenden wollen. Also in unserem Beispiel hätten wir vielleicht gerne das Attribut contentA. Wir können dies erreichen, indem wir in der __init__-Methode von B die __init__-Methode von A aufrufen. Dies geschieht durch die Anweisung ,,A.__init__(self)'', d.h. Name der Basisklasse plus Aufruf der __init__-Methode. Die Klasse A und die Tests bleiben gleich. Wir müssen lediglich die __init__-Methode in B wie folgt ändern:

class B(A):

    def __init__(self):
        A.__init__(self)
        self.contentB = "43"
        print("__init__ von B wurde ausgeführt")

Ruft man das gesamte Programm mit obiger Änderung auf, sehen wir, dass unser AttributeError verschwunden ist. Außerdem erkennen wir, dass die Instanz von y sowohl ein Attribut contentB als auch ein Attribut contentA enthält:

$ python3 inheritance4.py 
__init__ von A wurde ausgeführt
m von A wurde aufgerufen
__init__ von A wurde ausgeführt
__init__ von B wurde ausgeführt
m von B wurde aufgerufen
self.contentB von y: 43
self.contentA von y: 42

Dieses Vorgehen lässt sich analog auf andere Methoden übertragen. Im Folgenden rufen wir in m von B auch die Methode m von A auf:

    def m(self):
        A.m(self)
        print("m von B wurde aufgerufen")


Wir testen dieses Skript, das unter dem Namen inheritance5.py abgespeichert ist, in der interaktiven Python-Shell:

>>> from inheritance5 import A, B
>>> y = B()
__init__ von A wurde ausgeführt
__init__ von B wurde ausgeführt
>>> y.m()
m von A wurde aufgerufen
m von B wurde aufgerufen
>>>