Objektorientiere Programmierung: Etwas Theorie

Objektorientierte Programmierung hat drei wesentliche Merkmale, die für die Popularität dieser Art von Programmierung verantwortlich sind:

  • Kapselung
  • Vererbung
  • Polymorphismus

Sehen wir uns diese der Reihe nach an jeweils einem Beispiel an.

Klassenbildung

Klassen sind Abstraktionen von Dingen und Konzepten

Das Entwerfen einer Klasse bedeutet die Modellierung der benötigten Information. Wie jede Modellbildung impliziert das Abstraktion und Reduktion.

Abstraktion

Unter Abstraktion verstehen wir hier das Herausarbeiten von Gemeinsamkeiten und somit das Erkennen von übergeordneten Kategorien. Anna und Hans sind unterschiedliche Individuen, die eine für uns interessante Gemeinsamkeit haben: Sie sind beide Studierende. Indem wir erkannt haben, dass beide Individuen dieser Kategorie oder Klasse zugeordnet werden können (unsere Wahrnehmung tut dies ununterbrochen), haben wir bereits (zumindest implizit) ein Modell Studierende(r) entwickelt. Dieses muss nun für die Überführung in ein Computersystem noch genau formalisiert werden.

Reduktion

Jedes Modell (und eine Klasse ist nichts anderes als ein solches Modell) stellt eine Vereinfachung dessen dar, was modelliert wird. Für die Klasse Student müssen wir also überlegen, welche Daten so ein Modell beschreiben. Wir könnten ein extrem komplexes Modell entwerfen, in dem ein Student durch tausende Eigenschaften beschrieben wird, von der Schuhgröße bis zum bevorzugten Urlaubsland. Die eigentliche Kunst ist jedoch, sich auf die für den konkreten Anwendungsfall benötigten Daten zu beschränken, gleichzeitig aber auch keine zu vergessen.

Datenmodellierung

Der Schritt der Modellierung darf nicht unterschätzt werden. In dieser Phase geht es darum, eine klare Vorstellung davon zu bekommen, welche Objekte im Programm benötigt werden, welche Eigenschaften diese haben und wie sie mit ihrer Umwelt interagieren (Methoden).

Fehler in der Planungsphase zu erkennen und zu reparieren ist weniger aufwendig und damit weniger kostenträchtig, als wenn der Modellierungsfehler erst im halbfertigen Programm entdeckt wird.

In der Praxis zeichnet man hier echte "Konstruktionspläne" z.B. in Form von UML-Diagrammen. Diese dienen nicht nur als Hilfe beim Nachdenken über ein Projekt, sondern auch Kommunikationsmittel und zur Dokumentation.

Bei den Unterlagen im Moodle-Kurs gibt es ein eigenes Dokument zum Thema UML (Unified Modeling Language).

Kapselung

Kapselung beschreibt das Prinzip, dass ein Objekt in seinem Aufbau und seiner Funktionsweise sehr komplex sein kann, dass es aber nach außen nur eine vereinfachte Sichtweise, repräsentiert durch Schnittstellen bereitstellt.

Stellen wir uns eine Klasse vor, das ein Bankkonto repräsentiert:


In [ ]:
class BankAccount:
    "Represents a bank account."
    
    def __init__(self, account_number=None):
        "Initialize or create a new account."
        self.account_number = ''
        self.__balance = 0
        self.holder = None
        self._transactions = []
        
        if account_number is None:
            self._create_new_acconunt()
        else:
            self.account_number = account_number
            self._fetch_account_data(self, account_number)

    def get_balance(self):
        "Return the actual balance."
        return self.__balance
    
    def get_available_amount(self):
        "Return the maximum amount which can be withdrawn."
        rv = self.__balance + self._calculate_overdraft()
        if rv < 0:
            rv = 0
        return rv
        
    def make_deposit(self, amount, text):
        "Add money to this account."
        self.__balance += amount
        self.transactions.append(Transaction(amount, info=text))
        
    def withdraw(self, amount, text):
        "Withdraw amount from this account."
        overdraft = self._calculate_overdraft()
        if self.__balance + overdraft - amount > 0:
            self.__balance -= amount
            self.transactions.append(Transaction(amount * -1, info=text))
            return True
        else:
            raise BalanceTooLowException('You max amount is ...')
        
    def transfer_money(self, target_account, amount, text):
        "Transfer money from this account to target_account."
        overdraft = self._calculate_overdraft()
        if self.__balance + overdraft - amount > 0:
            # start some complecated procedurce which transfers money
            self.transactions.append(Transaction(amount * -1, target=target_account))
        else:
            raise BalanceTooLowException('You max amount is ...')
        
    def _create_new_account(self):
        "Create initial data for new account"
        self.holder = input('Enter name of account holder:')
    
    def _fetch_account_data(self, account_number):
        "Fetch some data from e.g. a database"
        # Missing: the DB code
        self.balance = account_data.get_balance()
        self.holder = account_data.get_holder()

Das ist nun relativ viel Code. Wesentlich aus der Sicht der Kapselung sind hier zwei Dinge:

Datenkapselung

Der Kontostand (__balance) darf von außen nicht direkt verändert werden, sondern nur über bestimmte Methoden:

* `make_deposit()`
* `withdraw()`
* `transfer_money()`

Dadurch wird sichergestellt, dass niemand ungeprüft den Kontostand verändern kann (also z.B. mehr abheben als erlaubt ist). Außerdem können wir in den Methoden Code haben, der z.B. jede Transaktion protokolliert.

Kapselung der Funktionalität

Ein Programmierer der mit so einem Account-Objekt interagieren will, braucht über die Funktionsweise des Objekts faktisch nichts zu wissen, sondern muss nur die Methoden kennen, die nach außen verfügbar sind:

* `get_balance()`
* `make_deposit()`
* `withdraw()`
* `transfer_money()`

Sollen 1000 Euro auf das Konto überwiesen werden reicht diese Codezeile

`make_deposit(1000, 'Erfolgsprämie für ...')`

Soll Geld auf ein anderes Konto überwiesen werden, kann dies ebenfalls mit einer Zeile erledigt werden:

`transfer_money('AT01234711223322', 1200, 'Miete für Dezember')`

Um die Details kümmert sich jeweils das Objekt selbst.

Die Kapselung der Funktionalität bietet einen weiteren Vorteil: So lange die Schnittstelle der Methode (d.h. ihr Name, die Parameter und der Rückgabewert) sich nicht verändern, kann der Funktionskörper jederzeit verändert werden, ohne dass Programme, die das Objekt nutzen, umgeschrieben werden müssen. Hier ein Beispiel:


In [1]:
class Product:
    
    def __init__(name, price):
        self.name = name
        self.price = price
        
    def get_gross_price(self):
        "Return gross price (with tax)."
        return self.prince  + self.price / 100 * 20

Sollte sich die Mehrwertsteuer ändern, kann dies in der Methode get_full_price() geändert werden. Existierender Code, der Objekte vom Typ Product nutzt, braucht nicht verändert zu werden. Viel wichtiger kann das natürlich werden, wenn sich zum Beispiel die Art verändert, wie Banken Überweisungen durchführen: man ändert den Körper der Methode transfer_money(), aus der Sicht eines Programms, das Account-Objekte nutzt, ändert sich nichts.

Vererbung

Unter diesem Begriff versteht man das Prinzip, das von einer Klasse jederzeit eine spezialisierte Unterform abgeleitet werden kann. Es entsteht so eine Hierarchie von Typen. Nehmen wir an, wir wollen für die Universitätsbibliothek eine Benutzerverwaltung schreiben, in der es unterschiedlichen Typen von Benutzern gibt. Zunächst erstellen wir einen Basistyp User:


In [ ]:
class MaxBorrowingsError(Exception): pass
class BookNotBorrowedException(Exception): pass

class User:
    
    def __init__(self, firstname, lastname):
        self.max_books = 10
        self.firstname = firstname
        self.lastname = lastname
        self.borrowed_books = []
        
    def borrow_book(self, book):
        if len(self.borrowed_books) < self.max_books:
            self.borrowed_books.append(book)
        else:
            raise MaxBorrowingsError('You have exceeded the number of books you are '
                                     'allowed to borrow.')
            
    def return_book(self, book):
        if book in self.borrowed_books:
            self.borrowed_books.remove(book)
        else:
            raise BookNotBorrowedException('You did not borrow this book!')

Ein Student ist ein spezieller User, der zusätzlich noch eine Matrikellnummer hat:


In [ ]:
class Student(User):
    
    def __init__(self, firstname, lastname, matrikelnummer):
        self.max_books = 10
        self.firstname = firstname
        self.lastname = lastname
        self.matrikelnummer = matrikelnummer
        self.borrowed_books = []

Hier sind 2 Dinge zu beachten:

  1. class Student(User) legt fest, dass die neue Klasse Student einen Spezialfall der Klasse User darstellt.
  2. Wir überschreiben (oder besser: überlagern) die Methode __init__(). Alle anderen Methoden erbt Student von der Klasse User.

In [ ]:
otto = Student('Otto', 'Huber', '017844556')
otto.borrow_book('A Book')
print(otto.borrowed_books)
otto.return_book('A Book')
print(otto.borrowed_books)

Wir sehen hier, dass die Methoden borrow_books() und return_books() zur Verfügung stehen, obwohl wir sie beim Schreiben der Klasse Student gar nicht definiert haben. Sie kommen von der Basisklasse User. Dieser Mechanismus ist sehr mächtig, weil wir nur die Teile einer Klasse zu verändern brauchen, die den Spezialfall der Elternklasse manifestieren.

Auf Typen testen

Wir haben schon mehrfach mit der Funktion type() den Typ eines Wertes abgefragt. Das funktioniert auch mit selbst geschriebenen Klassen, die ja eigene Typen festlegen:


In [ ]:
type(otto)

Wir können auch auf einen bestimmten Typen testen:


In [ ]:
type(otto) is Student

In [ ]:
isinstance(otto, Student)

Mit isinstance() können wir sogar testen, ob ein Wert einen bestimmten Typus hat, der weiter oben in der Vererbungskette steht:


In [ ]:
isinstance(otto, User)

Da Vererbung immer Spezialisierung bedeutet, ist in unserer Typhierarchie ein Student immer auch ein User.

Übung

Schreiben Sie eine weitere Klasse Diplomand, als Spezialfall von User, der sich von einem Student nur dadurch unterscheidet, dass max_books auf 30 steht.


In [ ]:

super()

super() ist eine spezielle Methode, die eine Referenz auf die Elternklasse liefert. Das kann nützlich sein, um explizit auf eine Methode der Elternklasse zuzugreifen, auch wenn diese in der Kindklasse überschrieben wird. Hier ein Anwendungsfall:


In [ ]:
class User2:
    
    def __init__(self, firstname, lastname):
        self.max_books = 10
        self.firstname = firstname
        self.lastname = lastname
        self.borrowed_books = []

        
class Student2(User2):
    
    def __init__(self, firstname, lastname, matrikelnummer):
        super().__init__(firstname, lastname)
        self.matrikelnummer = matrikelnummer
        
anna = Student2('Anna', 'Meier', '0175546655')        
print(anna.firstname, anna.matrikelnummer)

Polymorphie

Unter Polymorphie bzw. Vielgestaltigkeit versteht man die Fähigkeit, bestimmte Methoden an den Objekttyp anzupassen. Es geht also darum, dass unterschiedliche Typen gleichnamige und gleich aufzurufende Methoden haben, die aber an den jeweiligen Objekttyp angepasste Dinge tun.


In [ ]:
import math


class Rectangle:
    
    def __init__(self, length, width):
        self.length = length
        self.width = width
        
    def get_area(self):
        return self.length * self.width
    
    
class Circle:
    
    def __init__(self, radius):
        self.radius = radius
        
    def get_area(self):
        return self.radius ** 2 * math.pi
    
rect = Rectangle(60, 40)    
circ = Circle(25)

print(rect.get_area())
print(circ.get_area())

Das hat den Vorteil, dass wir die Formen in gleicher Weise verwenden können. Um etwas die Gesamtfläche mehrerer geometrischer Formen zu ermitteln können wir das tun:


In [ ]:
figures = [Rectangle(72, 55), Circle(42), Rectangle(22,19)]
sum([fig.get_area() for fig in figures])