18.2. Vererbung, Kapselung und Polymorphie#

Objektorientierte Programmierung OOP ist ein mächtiges Modellierungswerkzeug bei der wir Daten und Methoden in eine zusammenhängende Struktur bringen. Es deckt sich mit der ‚natürlichen‘ denkweise von uns Menschen. Wir denken gerne in Objekten wie Bäume, Häuser, Personen, Rechtecken, und so weiter und so fort. Deshalb kann die OOP dabei helfen die Komplexität einer Software besser zu begreifen. Dazu ist es notwendig, dass alle beteiligten Entwickler*innen dem OOP-Paradigma folgen und die gleiche Sprache sprechen.

Neben dem Strukturieren der Programmierlogik durch Objekte bietet die OOP weitere drei wichtige Konzepte an:

  1. Vererbung

  2. Kapselung

  3. Polymorphie

18.2.1. Vererbung#

Vererbung ist eine Möglichkeit Funktionalität und Attribute einer Eltern-Klasse in eine Kind-Klasse zu übernehmen – zu vererben. Wir sagen, die Kind-Klasse erbt von der Eltern-Klasse. Dabei geht es um Ähnlichkeiten.

Zum Beispiel könnte es Sinn machen eine Eltern-Klasse Person zu schreiben und unsere Kind-Klasse Student von dieser erben zu lassen. Ein Student ist eine spezielle Person. Wir könnten eine weitere spezielle Person, beispielsweise den Lecturer definieren.

Unsere Eltern-Klasse oder auch Basis-Klasse Person ist eine Abstraktion von Student und Lecturer:

class Person():
    def __init__(self, name, age):
        self.name = name
        self.age = age
        
    def __str__(self):
        return f'name: {self.name}, age: {self.age}'

    def say_name(self):
        print(f'My name is {self.name}.')

    def report(self):
        self.say_name()
        print(f'My age is: {self.age}')

Die beiden Kind-Klassen erben von Person und sind weniger abstrakt bzw. konkretere Dinge oder Subjekte:

class Student(Person):
    def __init__(self, sid, name, age):
        super().__init__(name, age)
        self.sid = sid
        self.type = 'learning'

    def __str__(self):
        return f'{super().__str__()}, sid: {self.sid}'

    def report(self, score):
        super().report()
        print(f'My id is: {self.sid}')
        print(f'My score is: {score}')
        print(f'I am a student.')

class Lecturer(Person):
    def __init__(self, name, age, title):
        super().__init__(name, age)
        self.title = title

    def __str__(self):
        return f'{self.title} {self.name}'

Student und Lecturer erben von Person, d.h. beide erhalten die Attribute name und age sowie alle Methoden, die in Person definiert wurden. Beide Kind-Klassen werden um die Attribute name und age erweitert. Diese Attribute können in den Kind-Klassen durch

self.name
self.age

angesprochen werden, was wir in __str__(self) von Lecturer demonstrieren.

Vererbte Objektattribute

Attribute eines Objekts egal ob vererbt oder nicht werden immer durch self.attributename und niemals über super().attributename angesprochen.

Student überschreibt die Methode report der Klasse Person, wohingegen Lecturer diese unberührt lässt. Damit wird Lecturer um die Methode report der Klasse Person erweitert.

Um zwischen diesen beiden gleichnamigen Methoden report der Klasse Person und Student zu unterscheiden, verwenden wir einmal self und einmal super(). Mit super() greifen wir auf die Methoden der Eltern-Klasse zu. Anstelle von super() können wir die Eltern-Klasse auch explizit angeben, müssen dann jedoch das self übergeben. Aus

...
super().__init__(name, age)
...

wird

...
Person.__init__(self, name, age)
...

Lassen Sie uns ein Objekt von jeder Klasse erzeugen und die Methoden testen:

person = Person('Bene', 25)
print(person)
person.report()
name: Bene, age: 25
My name is Bene.
My age is: 25
student = Student('3131', 'Anna', 22)
print(student)
student.report(413)
name: Anna, age: 22, sid: 3131
My name is Anna.
My age is: 22
My id is: 3131
My score is: 413
I am a student.
lecturer = Lecturer('Huber', 45, 'Prof.')
print(lecturer)
lecturer.report()
Prof. Huber
My name is Huber.
My age is: 45

Lecturer definiert keine Methode report, doch da Person eine solche Methode enthält mit diesem Namen definiert, existiert diese auch in Lecturer. Der folgende Code hätte die gleiche Wirkung:

class Lecturer(Person):
    def __init__(self, name, age, title):
        super().__init__(name, age)
        self.title = title

    def __str__(self):
        return f'{super().__str__()}, title: {self.title}'

    def report(self, score):
        super().report()

Es wird demnach die report Methode von Person aufgerufen! Alles in allem sparen wir Codezeilen bzw. doppelten Code. Es ist, zum Beispiel, möglich den Code aus Person.__init__() zu kopieren und die gesamte Initialisierung zu überschreiben:

class Lecturer(Person):
    def __init__(self, name, age, title):
        self.name = name
        self.age = age
        self.sid = sid
        self.title = title

    ...

Doch wenn wir die Initialisierung einer Person Person.__init__() ändern und sich diese Änderung auch auf alle Kinder auswirken soll, so müssten wir Lecturer.__init__() entsprechend anpassen.

Vererbung aber wann?

Gehen Sie besser sparsam mit der Vererbung um. Ruft die __init__-Methode nicht ihre Eltern-__init__ auf, so ist die Vererbung an dieser Stelle wahrscheinlich nicht die richtige Wahl der Modellierung.

18.2.2. Vererbung und Komposition#

Vererbung ist ein mächtiges Werkzeug, doch sollten Sie damit bedacht und sparsam umgehen. Tiefe Vererbungshierarchien tendieren dazu unverständlich zu werden. Die Mehrfachvererbung, welche wir in diesem Kurs nicht besprechen werden, ist in Python möglich, doch in den allermeisten Fällen nicht sinnvoll.

Vererbung aber wann?

Ob eine Vererbung sinnvoll ist entscheidet sich durch die Datenstrukturen die Sie bauen wollen und nicht unbedingt durch die Realgegenstände, die diese Strukturen modellieren.

So könnte man schnell zu dem Schluss kommen, dass ein Quadrat ein spezielles Rechteck ist und dass es somit eine gute Idee ist Quadrat von Rechteck erben zu lassen. Doch besitzt ein Quadrat lediglich eine Position und eine Breite wohingegen ein Rechteck noch eine zusätzliche Höhe besitzt. Vererben wir Rechteck an Quadrat erhält es ein überflüssiges Attribut. Das ist erst einmal nicht tragisch, wenn wir dem Rechteck jedoch eine Methode verpassen mit dem es seine Höhe verändern kann, geraten wir in Probleme. Denn damit kann ein Rechteck etwas, was ein Quadrat nicht kann – die Höhe nicht aber die Breite verändern. Die Datenstruktur Rechteck ist nicht länger einer Abstraktion von Quadrat.

Oftmals ist es besser die Komposition der Vererbung vorzuziehen. Komposition bedeutet, dass wir eine Klasse definieren, die als Attribute weitere komplexere Klassen beinhaltet. So könnten wir uns als Klasse ein Auto vorstellen, welches aus den Attributen Rad, Motor, usw. besteht.

class Car():
    def __init__(self, wheel, engine):
        self.wheel = wheel
        self.engine = engine
        ...

    ...

Rad und Motor könnten Klassen sein, die mit Funktionalität ausgestattet sind. Diese Funktionalität können wir dann in der Klasse Car nutzten. So gelangt Funktionalität nicht über Vererbung sondern durch Komposition in die Klasse und somit in seine Objekte.

18.2.3. Kapselung#

Kapselung ist ein weiteres fundamentales, wenn nicht sogar DAS fundamentalste aller Konzept der OOP. Dabei wird der gesamte, sich ändernden Zustand eines Programms in kleine Zuständigkeiten aka Objekte aufgeteilt.

Jedes Objekt kennt sein Innenleben und schützt dieses vor dem Zugriff von außen. Nach außen bietet das Objekt eine öffentliche Schnittstelle. Nur über diese ist es möglich das Innenleben des Objekts zu verändern. Demnach verändert das Objekt sein Innenleben selbst indem es durch seine öffentlichen Methoden dazu aufgefordert wird.

Die Einschränkung des Zugriffs auf bestimmte Methoden und Attribute eines Objekts ist ein wesentlicher Aspekt und wird durch die Klasse eines Objekts definiert. Die Idee dahinter ist es Komplexität vor dem Benutzer der Klasse zu verbergen und das ‚Innenleben‘ eines Objekts vor ungewollter Veränderung zu schützten. Der Benutzer muss lediglich wissen WAS eine Methode bewirkt, WIE dies erreicht wird bleibt verborgen und gehört zur Zuständigkeit des Objekts.

Angenommen wir konstruieren eine Klasse Circle mit den Attributen radius, center und diameter. Wir fügen noch eine Methode dist und contains hinzu. dist berechnet die Distanz zwischen dem Kreis und einem Punkt. Die Distanz ist negativ wenn sich der Punkt innerhalb des Kreises befindet. contains prüft ob sich ein Punkt innerhalb des Kreises befindet.

class Circle():
    def __init__(self, center, radius):
        self.center = center
        self.__radius = radius
        self.__diameter = 2*radius

    def __str__(self):
        return f'center: {self.center}, radius: {self.__radius}, diameter: {self.__diameter}'

    def contains(self, point):
        return self.dist(point) <= 0
        
    def dist(point):
        dx = self.center[0] - point[0]
        dy = self.center[1] - point[1]
        return (dx*dx + dy*dy)**0.5 - radius

    def set_radius(self, radius):
        self.__radius = radius
        self.__diameter = 2*radius

    def get_radius(self):
        return self.__radius

    def get_diameter(self):
        return self.__diameter

Zusätzlich bieten wir eine Methode set_radius an, welche den radius des Kreises ändert. Da der Durchmesser diameter vom Radius abhängt müssen wir, wann immer wir den Radius anpassen, auch den Durchmesser anpassen. Deshalb wollen wir dem Benutzer nicht erlauben, den Durchmesser selbst zu ändern. Damit der Benutzer nicht mehr direkt auf die Attribute radius und diamant zugreifen kann, fügen wir vor deren Namen zwei Unterstriche __ an. Dadurch werden diese Attribute privat.

circle = Circle((0,0), 3)
print(circle)
center: (0, 0), radius: 3, diameter: 6

Versuchen wir auf die geschützten Attribute zuzugreifen, erhalten wir einen Fehler:

circle.center = (6, 6)
print(circle)
print(circle.__radius)
center: (6, 6), radius: 3, diameter: 6
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[9], line 3
      1 circle.center = (6, 6)
      2 print(circle)
----> 3 print(circle.__radius)

AttributeError: 'Circle' object has no attribute '__radius'

Zwar können wir center verändern, da dies nicht geschützt ist, doch __radius lässt sich nicht von Außen verändern! Wir bezeichnen dieses Attribut als privates Attribut der Klasse.

In der Klasse finden sich die Methoden get_radius und set_radius über die wir den Radius wiederum verändern und auf den Wert des Radius zugreifen können:

circle.set_radius(10)
print(circle)
print(circle.get_radius())
center: (6, 6), radius: 10, diameter: 20
10

Doch dadurch dass wir eine Methode für die Veränderung verwenden, können wir sicherstellen, dass der Durchmesser ebenfalls korrekt abgeändert wird.

Durch die gleiche Schreibweise können wir auch Methoden in private Methoden umwandeln, sodass diese nur innerhalb der Klasse sichtbar und aufrufbar sind. Dies kann sinnvoll für Hilfsmethoden sein, die als solches, getrennt vom Aufruf anderer Methoden, nicht aufgerufen werden sollten.

Das obige Beispiel ist etwas künstlich, denn eigentlich macht das Attribut diameter an dieser Stelle keinen rechten Sinn. Eine bessere Variante bietet folgender Code:

class Circle():
    def __init__(self, center, radius):
        self.center = center
        self.__radius = radius

    def __str__(self):
        return f'center: {self.center}, radius: {self.get_radius()}, diameter: {self.get_diameter()}'

    def contains(self, point):
        return self.dist(point) <= 0
        
    def dist(point):
        dx = self.center[0] - point[0]
        dy = self.center[1] - point[1]
        return (dx*dx + dy*dy)**0.5 - radius

    def set_radius(self, radius):
        self.__radius = radius

    def get_radius(self):
        return self.__radius

    def get_diameter(self):
        return self.__radius*2

18.2.4. Polymorphie#

Polymorphie

Polymorphie ist ein Konzept, welches ermöglicht, dass eine Operation abhängig von ihrer Verwendung Werte/Objekte unterschiedlichen Datentyps verarbeiten kann.

Wir haben schon einige Beispiele gesehen in der Polymorphie zum Einsatz kam. Der +-Operator angewendet auf ganzen Zahlen und Zeichenketten wäre ein solches Beispiel:

print(3 + 6)
print('3' + '6')
9
36

Die Schnittstelle ist dabei der +-Operator und die unterschiedlichen Objekte sind einmal vom Typ int und einmal vom Typ str.

Aber auch unser Beispiel mit den Klassen Person, Student, Lecturer bietet Polymorphie. Die gemeinsame Schnittstelle ist die Basisklasse Person. Ein Student und Lecturer sind auch jeweils eine Person und bieten uns alle Attribute und Methoden die auch eine Person bietet. Deren konkrete Implementierung kann sich jedoch unterscheiden.

Wir könnten nun eine Funktion schreiben, die eine Person erwartet. Da diese Funktion mit Person als Typ umgehen kann, kann sie auch mit den Typen Lecturer und Student umgehen.

def printName(person):
    print(person.name)

student = Student('3131', 'Anna', 22)
lecturer = Lecturer('Huber', 45, 'Prof.')

printName(student)
printName(lecturer)
Anna
Huber

An dieser Stelle wissen wir nicht genau welchen Typ die Variable person hat. Wir wissen allerdings, dass was auch immer der Typ ist, dessen Basisklasse Person ist und deshalb ein Attribut name vorhanden sein muss.

Wundert es uns nicht, dass die Funktion print jeden Wert von jedwedem Datentyp ausgeben kann? Wie funktioniert das? Die Antwort lautet: Polymorphie! print(ob) geht davon aus, dass das Objekt ob eine Methode __str__() besitzt und diese eine Zeichenkette zurückliefert. Das ist der Grund weshalb auch unsere Objekte in schöner Art und Weise ausgegeben werden! Wir haben diese Methode ebenfalls definiert.

Funktionen und Methoden, welche andere Objekte verarbeiten erwarten von diesen ein bestimmte Menge an Methoden und Attribute. Diese Menge bezeichnen wir als Schnittstelle (engl. Interface).

Selbst unsere for-Schleife

for i in sequenz:
    # do something

erwartet von der Variablen sequenz, dass es sich um eine Sequenz handelt. Das heißt um ein Objekt das wir iterieren können. Es ist der Schleife egal ob es eine Liste, ein Bereich, ein Tupel oder sonst etwas iterierbares ist. Es muss lediglich die Schnittstelle Iterable erfüllen.

Es sei angemerkt, dass Polymorphie ein Konzept ist, was über die objektorientierte Programmierung hinausreicht. Auch in der prozeduralen wie auch funktionalen Programmierung nutzten wir die Polymorphie.