5. Objektorientierte Programmierung

5.1. Objektorientierte vs. Prozeduale Programmierung

Angenommen wir möchten ein Programm für die Universitätsbibliothek schreiben, welche die registrierten Studierenden sowie die in der Bibliothek verfügbaren Lehrbücher organisiert. Schaue wir uns zunächst an, wie ein solches Programm in einem prozedualen Programmierstil aufgebaut sein könnte.

Die Studierenden beispielsweise besitzen verschiedene Eigenschaften, wir in einer vorher definierten Reihenfolge in einer Liste ablegen könnten:

# Student: Name, Geschlecht, Alter, Studiengang, Semester, Notendurchschnitt
lisa  = ["Lisa", "w", 23, "Mathematik", 8, 1.6]
bernd = ["Bernd", "m", 25, "Maschinenbau", 6, 3.2]

Für Studierende könnte man nun freie Funktionen definieren, die mit diesen Eigenschaften zusammen einen logischen Programmablauf realisieren:

def celebrate_birthday(student):
    student[2] += 1

celebrate_birthday(lisa)
lisa
['Lisa', 'w', 24, 'Mathematik', 8, 1.6]

Dieser Programmierstil birgt allerdings einige Nachteile. Wenn wir im späteren Programmcode den Studiengang eines Studierenden wechseln wollen, werden wir uns noch daran erinnern, dass dieser an 4. Stelle der Liste steht? Außerdem könnte die freie Funktion celebrate_birthday auch auf alle anderen Listen mit mindestens 3 Elementen und einer Zahl an dritte Stelle angewendet werden. Unser Bibliotheksprogramm könnte die Lehrbücher beispielsweise wie folgt speichern

# Lehrbuch: Author, Titel, Regalstandort, Ausleihstatus
lehrbuch1 = ["Harro Heuser", "Lehrbuch der Analysis 1", 4, False]

und natürlich macht es keinen Sinn den Geburtstag einer Lehrbuchs zu feiern, dennoch würde unser Programm dies zulassen. Dann wird allerdings das Buch in das nächste Regal verschoben:

print("Unser Buch liegt in Regal", lehrbuch1[2])
print("Unser Buch feiert Geburtstag")
celebrate_birthday(lehrbuch1)
print("Unser Buch liegt in Regal", lehrbuch1[2])
Unser Buch liegt in Regal 4
Unser Buch feiert Geburtstag
Unser Buch liegt in Regal 5

Sinnvoll wäre es hier die Funktion celebrate_birthday direkt an Studierende zu koppeln.

Objektorientierte Programmierung ist ein Programmierstil bei dem Eigenschaften und darauf anzuwendende Operationen in individuellen Objekten gebündelt werden.

So ein Objekt ist beispielsweise ein Student oder eine Studentin, ausgestattet mit individuellen Eigenschaften wie Name, Geschlecht, Alter, Studiengang, Fachsemester, Notendurchschnitt. Eine Studentin führt gewisse Aktionen aus, beispielsweise Altern, Atmen, Lernen, eine Prüfung ablegen, etc..

Die Idee der Objektorientierten Programmierung ist es, die Klassifizierung Student/in mit einem eigens dafür vorgesehenen Datentyp, einer sogenannten Klasse, zu assozieren. Diese Klasse soll die vorher festgelegten Eigenschaften und Operationen bereitstellen.

So eine Klasse definiert man in Python nach folgender Syntax:

class <class_name>:
    def <method_1>(self, [parameter_list]):
        [do something]
        return <result>
    def <method_2>(self, [parameter_list]):
        [do something]
        return <result>    

Eine Klasse enthält Funktionen, wir nennen Sie Methoden, die auf Objekte dieser Klasse wirken. Innerhalb der Methoden können wir auf die individuallen Eigenschaften des Objektes zugreifen mit

Wir können anschließend im Hauptprogramm sogenannte Instanzen unserer Klasse erzeugen mit

<instance> = <class_name>([param_list])

Instanzen einer Klasse besitzen individuelle Eigenschaften, die Sie von anderen Instanzen unterscheidet. Auf diese können wir in den Methoden der Klasse zugreifen mit:

self.<variable>

Diese Variablen werden als Instanzvariablen bezeichnet.

Für die Verwaltung von Studierenden wäre für eine objektorientierte Lösung zunächst die folgende Definition einer Klasse sinnvoll:

class student:
    
    # Counter for number of students
    count = 0
    
    # Constructor
    def __init__(self, name, sex, age, course="nicht eingeschrieben", semester=0):
        
        print(name, "immatrikuliert")
        self.name     = name
        self.sex      = sex
        self.age      = age
        self.course   = course
        self.semester = semester
        self.marks    = []
        
        student.count += 1
    
    # Destructor
    def __del__(self):
        print(self.name, "exmatrikuliert")
        student.count -= 1
        
    # Increases and returns age of a student
    def celebrate_birthday(self):
        print(self.name, "feiert Geburtstag")
        self.age += 1 
        return self.age

Dieser Code erfordert eine ausführlichere Erläuterung:

  • Wir haben eine Klasse namens student definiert.

  • Die Klasse besitzt eine Funktion namens __init__, welche als Funktionsparameter eine Referenz auf sich selbst namens self, sowie und die Parameter name, sex, age, course und semester annimt. Für course und semester wurden Default-Werte angegeben, dazu später mehr. Die Funktion Namens __init__ wird als Konstruktor bezeichnet und wird beim initialisieren einer Instanz (also ein Objekt vom Typ student) aufgerufen.

  • Die Klasse besitzt eine Funktion namens __del__, der sogenannte Destruktor. Dieser ist das Gegenstück zum Konstruktor und wird beim Zerstören einer Instanz aufgerufen.

  • Die Klasse besitzt eine Klassenvariable namens count. Diese Variable ist nicht an eine Instanz gekoppelt. Alle Instanzen dieser Klasse teilen sich also diese Variable.

  • Die Klasse besitzt Instanzvariablen name, sex, age, course, semester, mark, welche in allen Konstruktoren zunächst initialisiert werden müssen.

  • Die Klasse besitzt eine Methode namens celebrate_birthday. Methoden sind Funktionen, welche an eine Instanz dieser Klasse gekoppelt sind und dürfen auf alle Klassen- und Instanzvariablen zugreifen.

Beachte: Bei allen Methoden einer Klasse ist der erste Funktionsparameter immer self, also der Zeiger auf die Instanz selbst. Beim Aufruf einer Funktion wird dieses Objekt nicht mit übergeben.

In folgendem Beispiel sehen wir, wie wir Instanzen einer Klasse erzeugen und Zerstören, auf Klassenvariablen zugreifen und Methoden aufrufen:

# Instanzen der Klasse student erzeugen (ruft Konstruktor auf)
lisa = student("Lisa", "w", 23, "Mathematik", 8)
bernd = student("Bernd", "m", 28, "Maschinenbau", 6)
Lisa immatrikuliert
Bernd immatrikuliert
# Zugriff auf Klassenvariable
print("Anzahl Studierende:", student.count)
Anzahl Studierende: 2
# Aufrufen einer Methode
new_age = lisa.celebrate_birthday()
print("Lisa ist jetzt", new_age, "Jahre alt")
Lisa feiert Geburtstag
Lisa ist jetzt 24 Jahre alt
# Instanz zerstören (ruft Destruktor auf)
del bernd
print("Anzahl Studierende:", student.count)
Bernd exmatrikuliert
Anzahl Studierende: 1

Man könnte noch weitere Konstruktoren mit einer unterschiedlichen Anzahl an Funktionsparametern definieren. Beachte, dass wir mit unserer jetzigen Implementierung nur Instanzen anlegen können, wenn wir beim initialisieren 5 Parameter bereitstellen. Folgendes wird nicht funktionieren,

bernd = student()

es sei denn, wir definieren einen weiteren Konstruktor __init__(self), in dem die lokalen Variablen eventuell mit Standard-Einträgen initialisiert werden.

Was allerdings mit unserer Implementierung möglich ist, ist eine Initialisierung mit 3 oder 4 Argumenten. Im Kopf des Konstruktors ist der 4. Parameter course="nicht eingeschrieben" und der 5. Parameter semester=0, was bedeutet, dass wenn wir die Funktion mit nur 3 Parametern aufrufen, werden der 4. und 5. Parameter auf den Default-Wert "nicht eingeschrieben" bzw. 0 gesetzt:

bernd = student("Bernd", "m", 28)
print("Bernds Studiengang :", bernd.course)
print("Bernds Semester    :", bernd.semester)
Bernd immatrikuliert
Bernds Studiengang : nicht eingeschrieben
Bernds Semester    : 0

Wir fassen die Bestandteile des objektorientierten Programms zusammen:

Bezeichnung

Beispiel

Klasse

student

Instanz

lisa, bernd

Methode

student.celebrate_birthday()

Instanzvariable

lisa.name, lisa.age, …

Klassenvariable

student.count

Mit der Syntax

<instanz>.<locale variable>

können wir auf die Instanzvariablen zugreifen, und mit

<instanz>.<function>(<param1>, <param2>, ...)

Methoden aufrufen.

Spezielle Methoden in einer Klasse sind

Name

Bezeichnung

Bemerkung

__init__(self, <param_list>)

Konstruktor

Beim Erstellen einer Instanz aufgerufen

__del__(self)

Destruktor

Beim Löschen einer Instanz mit del <instance> aufgerufen

5.2. Methoden und Instanzvariablen

Fügen wir nun noch einige Funktionen zu unserer Klasse hinzu. Wir könnten im Jupyter-Notebook die Zelle, in der wir die Klasse student definiert haben, editieren, oder die Klasse durch folgenden rekursiven Aufruf um eine Funktion erweitern:

class student(student):
    # Füge Prüfungsergebnis hinzu
    def add_exam(self, mark):
        self.marks.append(mark)
        
    # Berechne Notendurchschnitt
    def get_average_mark(self):
        nr_marks = len(self.marks)
        if nr_marks > 0:
            return sum(self.marks) / len(self.marks)
        else:
            return 0

Da wir die Klasse student verändert haben, müssen wir die Instanz lisa nochmal neu anlegen. Für diese Instanz können wir nun Prüfungsnoten hinzufügen und den Notendurchschnitt berechnen lassen:

lisa = student("Lisa", "w", 23, "Mathematik", 8)
lisa.add_exam(2.0)
lisa.add_exam(1.3)
lisa.get_average_mark()
Lisa immatrikuliert
Lisa exmatrikuliert
1.65

5.2.1. Spezielle Methoden

Schauen wir uns nochmal den Konstruktor __init__ an. Dieser wird nicht explizit aufgerufen, sondern bei der Initialisierung einer Klasseninstanz über student(...). Es gibt noch weitere solcher Funktionen, die, falls sie in der Klasse implementiert sind, die weitere Arbeit mit der Klasse eleganter gestaltet.

Probieren wir nun mal die Instanz lisa auf der Konsole auszugeben:

print("Das ist Lisa:", lisa)
Das ist Lisa: <__main__.student object at 0x7fcb6065f100>
lisa
<__main__.student at 0x7fcb6065f100>

Standardmäßig wird ein Text ausgegeben, der uns verrät zu welcher Klasse Lisa gehört, und an welcher Stelle des Speichers Lisa liegt. Wünschenswert wäre vielleicht eine schön formattierte Konsolenausgabe.

Dazu müssen wir die Klasse um 2 weitere versteckte Funktionen erweitern:

class student(student):
    
    # Aufgabe einer Instanz als String
    def __to_string__(self):
        return self.name + ", "  \
    + ("männlich" if self.sex == "m" else "weiblich") + ", " \
    + str(self.age) + " Jahre, " \
    + self.course + " (" + str(self.semester) + ". Semester)"
    
    # Konvertierung zu String bei print
    def __str__(self):
        return self.__to_string__()
    
    # Konvertierung zu String bei Standard-Konsolenausgabe
    def __repr__(self):
        return self.__to_string__()

Die Methode __str__ wird bei der Konsolenausgabe mit print aufgerufen, und die Methode __repr__, falls wir einfach nur lisa in die Konsole eintippen. Da beide Funktionen hier das Gleiche ausgeben sollen, haben wir die eigentliche Umwandlung in einen String in die Funktion to_string ausgelagert.

lisa = student("Lisa", "w", 23, "Mathematik", 8)
print("Das ist Lisa:", lisa)
Lisa immatrikuliert
Das ist Lisa: Lisa, weiblich, 23 Jahre, Mathematik (8. Semester)
lisa
Lisa, weiblich, 23 Jahre, Mathematik (8. Semester)

Wir haben bei der Methode __to_string__ auch doppelte Unterstriche vorangestellt. Dies bedeutet im Allgemeinen, dass die Methode privat ist und lediglich von anderen Mehoden der Klasse, aber nicht von außen aufgerufen werden soll. Prinzipiell ist der Aufruf lisa.__to_string()__ zwar erlaubt, sollte aber vom Programmierer nicht verwendet werden.

Ferner sind Vergleichsoperationen interessant. Wir wollen vielleicht über Vergleiche mit < Personen nach Namen sortieren, oder mit == doppelt angelegte Instanzen ermitteln.

Solche Vergleichsoperationen müssen natürlich irgendwo in unserer Klasse definiert sein. Dazu implementiert man die Funktion __lt__(self,other) (“lt” steht für “less than”), und für die Operationen <= noch __le__ (less or equal) und für == noch __eq__(self,other) (equal):

class student(student):
    
    # Comparison operation <
    def __lt__(self, other):
        return self.name < other.name    
    
    # Comparison operation <=
    def __le__(self, other):
        return self.name <= other.name
    
    # Comparison operation ==
    def __eq__(self, other):
        return self.name == other.name
lisa  = student("Lisa", "w", 23, "Mathematik", 8)
bernd = student("Bernd","m", 25, "Maschinenbau", 6)
lisa2 = student("Lisa", "w", 21, "Psychologie", 2)

print("Lisa kleiner Bernd       : ", lisa < bernd)
print("Lisa kleiner/gleich Lisa : ", lisa <=bernd)
print("Lisa gleich Lisa2        : ", lisa == lisa2)
print("Lisa größer Bernd        : ", lisa > bernd)
print("Lisa größer/gleich Bernd : ", lisa >= bernd)
Lisa immatrikuliert
Bernd immatrikuliert
Bernd exmatrikuliert
Lisa immatrikuliert
Lisa kleiner Bernd       :  False
Lisa kleiner/gleich Lisa :  False
Lisa gleich Lisa2        :  True
Lisa größer Bernd        :  True
Lisa größer/gleich Bernd :  True

Mit der Definition von __le__ bzw __lt__ wurden auch automatisch die Operatoren > bzw. >= definiert. Da wir nun eine Vergleichsoperation haben, ist auch der sort-Befehl in Listen von Instanzen der Klasse student ausführbar:

student_list = [student("Lisa", "w", 23, "Mathematik", 8),
                student("Bernd", "m", 25, "Maschinenbau", 6),
                student("Tom", "m", 32, "Psychologie", 22),
                student("Anna", "w", 19, "Chemie", 2)]

student_list.sort()
student_list
Lisa immatrikuliert
Bernd immatrikuliert
Tom immatrikuliert
Anna immatrikuliert
[Anna, weiblich, 19 Jahre, Chemie (2. Semester),
 Bernd, männlich, 25 Jahre, Maschinenbau (6. Semester),
 Lisa, weiblich, 23 Jahre, Mathematik (8. Semester),
 Tom, männlich, 32 Jahre, Psychologie (22. Semester)]

Auch unsere Implementierung der String-Darstellung aus __str__ wurde bei der Konsolen-Ausgabe verwendet.

5.2.2. Überladung von Operatoren

Auch die üblichen Rechenoperationen lassen sich für eigene Klassen definieren. Wie schon im Kapitel {ref}numpy gesehen, war die Addition, Subtraktion, Multiplikation und Division für Instanzen vom Typ numpy.ndarray definiert. Um dies für unsere student-Klasse zu realisieren müssen wir für die Realisierung der +-Operation die Methode __add__(self, other) implementieren:

import random

class student(student):    
    # Addition operator (+)
    def __add__(self, other):
        print("Addition aufgerufen")
        name = "Das Kind von "+self.name+" und "+other.name
        sex = "w" if (random.random()<0.5) else "m"
                
        child = student(name, sex, 0)
        return child

Die Addition zweier Instanzen der Klasse student erzeugt eine weitere Instanz. In unserem Fall bewirkt die Addition, dass die 2 Summanden ein Kind zeugen, welches mit 50%-iger Wahrscheinlichkeit männlich und andernfalls weiblich ist:

lisa  = student("Lisa", "w", 23, "Mathematik", 8)
bernd = student("Bernd","m", 25, "Maschinenbau", 6)
kind = lisa+bernd

print("Ein Kind wurde geboren:", kind)
Lisa immatrikuliert
Lisa exmatrikuliert
Bernd immatrikuliert
Bernd exmatrikuliert
Addition aufgerufen
Das Kind von Lisa und Bernd immatrikuliert
Ein Kind wurde geboren: Das Kind von Lisa und Bernd, weiblich, 0 Jahre, nicht eingeschrieben (0. Semester)

In der folgenden Tabelle sind weitere vordefinierte Methoden zur Operatorüberladung zusammengefasst:

Operator

Methode

+

__add__(self,other)

-

__sub__(self,other)

*

__mul__(self,other)

/

__div__(self,other)

**

__pow__(self,other)

==

__eq__(self, other)

>=

__ge__(self, other)

>

__gt__(self, other)

<=

__le__(self, other)

<

__lt__(self, other)

5.3. Vererbung

Bei der Verwerbung übernimmt eine Klasse die Eigenschaften und Methoden einer anderen Klasse und ergänzt diese gegebenenfalls durch weitere Eigenschaften und Methoden. Dies ist sinnvoll, wenn man mit mehreren Klassen arbeiten möchte, für die viele Eigenschaften gleich sind. Um eine Klasse von einer anderen abzuleiten nutzen wir die Syntax

class <sub_class>(<base_class>):
    [...]

Als Beispiel betrachten wir

class animal:
    # Klassenvariablen
    description = "unknown animal"
    region = "somewhere"
            
    # Konsolenausgabe für alle Tiere
    def __str__(self):
        return "I am a "+self.description+" and I live in/at "+self.region+".";
    
class fish(animal):
    # Klassenvariablen
    description = "fish"
    region = "water";
    
    # Konstruktor für Fische
    def __init__(self, color):        
        self.color = color
        
class mammal(animal):
    # Klassenvariablen
    description = "mammal"
    region = "land"
    
    def __init__(self, nr_legs):
        self.nr_legs = nr_legs

# Hauptprogramm beginnt hier
carp = fish("blue")
print(carp)

monkey = mammal(2)
print(monkey)
I am a fish and I live in/at water.
I am a mammal and I live in/at land.

Die Klassenvariablen description und region sind sowohl in der Basisklasse, als auch in der abgeleiteten Klasse definiert. Diese nehmen aber stets den Wert der abgeleiteten Klasse an. Die Funktion für die Konsolenausgabe __str__ mussten wir damit nur in der Basisklasse definieren.

Wenn wir nun die __str__-Funktion in der abgeleiteten Klasse mammal erneut implementieren, dann wird auch diese für alle Objekte vom Typ mammal aufgerufen und nicht die Implementierung der Basisklasse. Wir können aber mit animal.__str__ weiterhin auf die Implementierung dieser Funktion aus der Basisklasse zugreifen:

class mammal(animal):
    
    description = "mammal"
    region = "land"
    
    def __init__(self, nr_legs):
        self.nr_legs = nr_legs
        
    def __str__(self):
        return animal.__str__(self)+" I have "+str(self.nr_legs)+" legs."
    
human = mammal(2)
print(human)
I am a mammal and I live in/at land. I have 2 legs.

Python erlaubt natürlich auch weitere Ebenen der Vererbung. Wir könnten von unserer Klasse für Säugetiere noch weitere Klassen ableiten, für Nagetiere, Huftiere, etc.. Auch eine Mehrfachvererbung ist möglich. Möchten wir eine Klasse von 2 anderen ableiten, dann schreiben wir einfach

class <sub_class>(<base_class_1>, <base_class_2>[, ...]):
    [...]

und unsere neue Klasse erbt alle Eigenschaften und Methoden von <base_class_1> und <base_class_2>. Kritisch wird es nur dann, wenn beide Klassen Methoden mit gleichem Namen aber unterschiedlicher Implementierung bereitstellen:

class horse:
    def __init__():
        print("Horse constructor called")
    def output():
        print("Hüüüü")
class human:
    def __init__():
        print("Human constructor called")
    def output():
        print("Arghh")

# Klasse für ein Mischwesen
class centaur(horse, human):
    pass

a = centaur
a.output()
Hüüüü

5.4. Iteratoren

Wir haben schon einige Datentypen gesehen, über die wir in einer for-Schleife iterieren können, beispielsweise list, tuple, string. Man kann auch selbst programmierte Klassen iterierbar machen. Wichtig ist hierfür die Implementierung der folgenden 2 Funktionen:

  • __iter__(self): Gibt das erste Element der Iteration zurück

  • __next__(self): Gibt das Nachfolgeelement der Iteration zurück oder wirft die Exception StopIteration

Als Beispiel implementieren wir eine Klasse zur Ziehung der Lottozahlen:

import random

class LotteryNumbers:
    
    def __iter__(self):
        self.n = 0                 # Initialisiere Zähler
        return self                # Rückgabe des aktuellen Objekts
    def __next__(self):
        self.n += 1                # Inkrementiere Zähler
        if self.n >=7:
            raise StopIteration    # Abbruch nach 6 Zahlen
        else:
            return random.randint(1,49) # Erzeuge Zufallszahl

Diese 2 Funktionen reichen aus um mit einer for-Schleife über dieses Objekt zu iterieren:

for i in LotteryNumbers():
    print("Die Zahl", i, "wurde gezogen.")
Die Zahl 45 wurde gezogen.
Die Zahl 29 wurde gezogen.
Die Zahl 9 wurde gezogen.
Die Zahl 15 wurde gezogen.
Die Zahl 46 wurde gezogen.
Die Zahl 35 wurde gezogen.

Ein iterierbares Objekt lässt sich auch wie folgt manuell durchgehen:

numbers = iter(LotteryNumbers()) # Erzeuge Iterator
try:
    print("Lottozahl", next(numbers))
    print("Lottozahl", next(numbers))
    print("Lottozahl", next(numbers))
    print("Lottozahl", next(numbers))
    print("Lottozahl", next(numbers))
    print("Lottozahl", next(numbers))
    print("Lottozahl", next(numbers))
    print("Lottozahl", next(numbers))
except:
    print("Alle Zahlen wurden gezogen")
Lottozahl 41
Lottozahl 31
Lottozahl 2
Lottozahl 27
Lottozahl 47
Lottozahl 46
Alle Zahlen wurden gezogen

Das Konstrukt bestehend aus try und except dient zum “Exception-Handling”. Python versucht den Block unter try auszuführen und springt, sobald eine dieser Funktionen eine Exception wirft (beachte die Zeile raise StopIteration in LotteryNumbers.__next(self)__), zum except-Block.

5.5. Code-Dokumentation

Damit es potentielle Anwender unserer selbst programmierten Klassen einfacher haben, ist eine ordentliche Code-Dokumentation sehr hilfreich. Wir können die Klasse selbst, sowie alle Methoden mit Kommentaren der Form

"""
[Comment]
...
[Comment]
"""

versehen. Dieser Text erscheint dann im Hilfetext im Jupyter-Notebook, wenn wir mit ? nach der Dokumentation einer Klasse oder Methode fragen. Hier ein Beispiel einer gut dokumentierten Klasse:

class Car:
    """
    A car object, equipped with a model name and color.
    """
    
    def __init__(self, model, color="gray"):
        """
        Constructor
        
        Initializes a new car object with a default name. The engine is off.
        
        Parameters
        ----------
        model: string
            Model of the car (e.g. VW, Mercedes, ...)
        color: string, optional
            Color of the car (e.g. red, blue, ...). Default value: gray
        """
        self.model = model
        self.color = color
        self.engine_on = False
        
    def start_engine(self):
        """
        start_engine(self)
        
        Method that allows to start the engine
        
        Example
        -------
        >>> mercedes = Car()
        >>> mercedes.start_engine()
        """
        
        self.engine_on = True
    
    def engine_status(self):
        """
        engine_status()
        
        Returns the engine status.
        
        Returns
        -------
        out: bool
            Returns True when the engine is on or False when it is off
            
        See Also
        --------
        start_engine : Method used to start the engine
        """
        
        return engine_on

Wir bekommen nun mit

Car?

unsere Klassendokumentation angezeigt und mit

Car.engine_status?

die Dokumentation der engine_status-Methode.