Home | Lehre | Videos | Texte | Vorträge | Software | Person | Impressum, Datenschutzerklärung | Blog RSS

Stand: 2024-05-27
weitgehend formuliert von ChatGPT 4o

Objektorientierte Programmierung, Teil 2

Vererbung

Vererbung ist ein wichtiges Konzept in der objektorientierten Programmierung. Es ermöglicht, eine neue Klasse (Kindklasse) zu erstellen, die die Eigenschaften und Methoden einer bestehenden Klasse (Elternklasse) übernimmt. Dies kann helfen, Code wiederzuverwenden und die Struktur von Programmen klarer und wartbarer zu gestalten.

Angenommen, wir möchten ein Bestellsystem für einen Online-Shop entwickeln. Wir haben eine allgemeine Klasse Produkt, und daraus abgeleitet spezifische Klassen für verschiedene Produkttypen wie Buch und Elektronik.

Schritt 1: Definition der allgemeinen Klasse Produkt

class Produkt:
    def __init__(self, name, preis):
        self.name = name
        self.preis = preis

    def zeige_info(self):
        print(f'Produkt: {self.name}, Preis: {self.preis:.2f} EUR')

Schritt 2: Definition der spezialisierten Klassen

class Buch(Produkt):
    def __init__(self, name, preis, autor):
        super().__init__(name, preis)
        self.autor = autor

    def zeige_info(self):
        super().zeige_info()
        print(f'Autor: {self.autor}')

class Elektronik(Produkt):
    def __init__(self, name, preis, garantie):
        super().__init__(name, preis)
        self.garantie = garantie

    def zeige_info(self):
        super().zeige_info()
        print(f'Garantie: {self.garantie} Jahre')

super() wird benutzt, um auf Methoden der Elternklasse = Oberklasse = superclass zuzugreifen, die in der Kindklasse überschrieben sind. Warum würde im Beispiel oben self.zeige_info() statt super().zeige_info() Unsinn sein? Vorsicht: self ist eine Variable, aber super() ein Funktionsaufruf, also mit Klammern dahinter.

Schritt 3: Verwendung der Klassen

# Erstellen von Produktinstanzen
produkt1 = Produkt('Allgemeines Produkt', 19.99)
buch1 = Buch('Der Python-Kurs', 29.99, 'John Doe')
elektronik1 = Elektronik('Smartphone', 399.99, 2)

# Informationen anzeigen
produkt1.zeige_info()
# Ausgabe: Produkt: Allgemeines Produkt, Preis: 19.99 EUR

buch1.zeige_info()
# Ausgabe: 
# Produkt: Der Python-Kurs, Preis: 29.99 EUR
# Autor: John Doe

elektronik1.zeige_info()
# Ausgabe:
# Produkt: Smartphone, Preis: 399.99 EUR
# Garantie: 2 Jahre

Klassenhierarchie

Durch die Verwendung von Vererbung können wir die allgemeine Funktionalität in der Produkt-Klasse definieren und dann spezialisierte Klassen wie Buch und Elektronik erstellen, die diese Funktionalität erweitern oder anpassen. Dies führt zu einem klaren und übersichtlichen Code, der leicht zu erweitern und zu warten ist.

Produkt ist die Oberklasse = Basisklasse = Elternklasse der Klassen Buch und Elektronik. Diese letzteren beiden Klassen sind Unterklassen = Ableitungen = Kindklassen von Produkt.

Das Spiel lässt sich weitertreiben, indem etwa eine Klasse Notebook von der Klasse Elektronik erbt. Notebook wäre dann eine Kindklasse einer Kindklasse von Produkt. (Nein, man sagt nicht Enkelklasse dazu.)

Überschreiben von Methoden

Das Überschreiben (engl. overriding) von Methoden bedeutet, dass eine Methode in einer abgeleiteten Klasse eine Methode der Basisklasse mit demselben Namen ersetzt. Dadurch kann das Verhalten der Methode in der abgeleiteten Klasse spezifiziert oder angepasst werden.

Das Überschreiben von Methoden ist aus mehreren Gründen nützlich:

  1. Spezialisierung des Verhaltens: Es ermöglicht einer abgeleiteten Klasse, das Verhalten der geerbten Methoden zu spezifizieren und zu ändern.

  2. Polymorphismus: Es unterstützt das Konzept des Polymorphismus, bei dem eine Methode unterschiedlich implementiert wird, basierend darauf, welches Objekt sie aufruft. (Nachher mehr dazu.)

  3. Wiederverwendbarkeit und Wartbarkeit: Durch das Überschreiben können wir Code wiederverwenden und dennoch flexible und erweiterbare Entwürfe erstellen, ohne die Basisklasse ändern zu müssen.

Formalitäten

Um keine große Verwirrung zu erzeugen, sollte die überschriebende Methode dieselben Typen an Parametern nehmen wie die Oberklasse. (In C++, Java usw. ist das Pflicht für das Überschreiben.)

Ein Vertipper beim Namen der Methode führt dazu, dass man in der Kindklasse nicht die Methode der Elternklasse überschreibt, sondern eine neue Methode anlegt, mit fast, aber eben auch nur fast, gleichem Namen. Um diesen beliebten Fehler automatisch entdecken zu lassen, kann man ab Python 3.12 in die Zeile vor die Definition der überschreibenden Methode in der Kindklasse ein @override schreiben. Dazu ist derzeit noch vorher ein from typing import override nötig.

Beispiel: Online-Shop-Bestellsystem

Hier ist das Beispiel von oben noch einmal, aber mit einem Fokus auf das Überschreiben der zeige_info-Methode.

Die Produkt-Klasse definiert eine allgemeine Methode zeige_info, die grundlegende Informationen über das Produkt anzeigt:

class Produkt:
    def __init__(self, name, preis):
        self.name = name
        self.preis = preis

    def zeige_info(self):
        print(f'Produkt: {self.name}, Preis: {self.preis:.2f} EUR')

Die Buch-Klasse erbt von Produkt und fügt das Attribut autor hinzu. Die Klasse Buch überschreibt die Methode zeige_info, um die spezifischen Informationen des Buches anzuzeigen. super().zeige_info() ruft die zeige_info-Methode der Basisklasse auf, um zunächst die allgemeinen Produktinformationen anzuzeigen:

class Buch(Produkt):
    def __init__(self, name, preis, autor):
        super().__init__(name, preis)
        self.autor = autor

    def zeige_info(self):
        super().zeige_info()
        print(f'Autor: {self.autor}')

Entsprechendes passiert in der Methode zeige_info der Elektronik-Klasse:

class Elektronik(Produkt):
    def __init__(self, name, preis, garantie):
        super().__init__(name, preis)
        self.garantie = garantie

    def zeige_info(self):
        super().zeige_info()
        print(f'Garantie: {self.garantie} Jahre')

Durch das Überschreiben der zeige_info-Methode in den abgeleiteten Klassen können wir spezifische Informationen für unterschiedliche Produkttypen anzeigen, ohne die allgemeine Logik der Basisklasse Produkt zu verändern. Dies zeigt, wie Vererbung und Überschreiben zusammenarbeiten, um einen flexiblen und erweiterbaren Code zu erstellen.

Polymorphie

Polymorphie ist ein fundamentales Konzept in der objektorientierten Programmierung, das es Objekten verschiedener Klassen ermöglicht, auf die gleiche Art angesprochen zu werden. Eine der Hauptideen hinter Polymorphie ist, dass Objekte einer Kindklasse (und Kindeskindklasse usw.) überall dort verwendet werden können, wo Objekte der Elternklasse erwartet werden. Das heißt, Kindklassen (Unterklassen) können die Elternklassen (Oberklassen) vertreten. Die Kindklassen können dabei aber zusätzliche Attribute und Methoden haben und sie können Methoden ihrer Elternklasse überschreiben und damit den Effekt dieser Methoden ändern.

Beispiel: Liste von Produkten

Der Online-Shop aus dem bereits verwendeten Beispiel benötigt einen Datenspeicher für den Gesamtbestand an Produkten. Hierbei verwenden wir eine Liste, die Objekte verschiedener Produktklassen enthält, aber dennoch als Liste von Produkt-Objekten betrachtet wird.

Dank Polymorphie können wir eine Liste von Produkten definieren, die sowohl Produkt, Buch als auch Elektronik-Objekte enthält, denn Buch und Elektronik können ihre Oberklasse Produkt vertreten. Die Methode zeige_info wird korrekt für jeden Produkttyp aufgerufen.

# Liste von verschiedenen Produktobjekten
produkte: list[Produkt] = [
    Produkt('Allgemeines Produkt', 19.99),
    Buch('Der Python-Kurs', 29.99, 'John Doe'),
    Elektronik('Smartphone', 399.99, 2)
]

# Informationen anzeigen für alle Produkte in der Liste
for produkt in produkte:
    produkt.zeige_info()
    print()  # Leere Zeile zur besseren Lesbarkeit

Vorteile der Polymorphie

Durch Polymorphie können wir eine einheitliche Schnittstelle (Sammlung der Methoden) für verschiedene Produkttypen bereitstellen, was zu einem flexiblen, erweiterbaren und gut strukturierten Code führt.

Kapselung und Unterstriche

Sprachen wie C++ und Java sind sehr strikt darin, festzulegen, welche Klassen auf welche Bestandteile welcher anderen Klassen zugreifen können. In Python ist das lockerer. Unterstriche vor den Namen von Attributen und Methoden spielen dabei die Hauptrolle.

Einfacher Unterstrich (_)

Ein einzelner Unterstrich vor einem Attribut oder einer Methode ist eine Konvention, die anzeigt, dass das Element als geschützt betrachtet werden soll, sozusagen unter der Motorhaube liegend oder versehen mit der Aufschrift Finger weg!. Es soll (soll!) nicht direkt von außerhalb der Klasse verwendet werden. Das ist wohlgemerkt nur ein soll nicht, aber kein darf nicht.

Der Gedanke hinter diesem (zugegebenermaßen schwachen) Schutz ist, dass man dann unter der Motorhaube der Klasse Änderungen vornehmen kann, ohne auf andere Klassen Rücksicht nehmen zu müssen. Das gelingt um so besser, je mehr Attribute und Methoden man mit dem Unterstrich als geschützt markiert und je mehr sich andere Klassen an diesen Hinweis halten, also diese Attribute und Methoden nicht direkt ansprechen.

Ein Beispiel:

class Beispiel:
    def __init__(self):
        self._geschuetztes_attribut = 42

    def _geschuetzte_methode(self):
        print('Diese Methode ist geschützt.')

obj = Beispiel()
print(obj._geschuetztes_attribut)  # 42
obj._geschuetzte_methode()         # Diese Methode ist geschützt.

In diesem Beispiel werden _geschuetztes_attribut und _geschuetzte_methode mit einem einfachen Unterstrich versehen, um anzuzeigen, dass sie nicht außerhalb der Klasse verwendet werden sollten.

Doppelte Unterstriche (__)

Doppelte Unterstriche vor einem Attribut oder einer Methode sind erstens ein (minimal) stärkerer Schutz und ersparen zweitens Stress beim Ableiten von Klassen. Doppelte Unterstriche führen anders als der einfache Unterstrich zu einer Aktion der Python-Laufzeitumgebung: Intern wird dem Namen des Attributs oder der Methode der Name der Klasse vorangestellt, mit einem führenden Unterstrich.

Dies ist keine ernstzunehmende Schutzmaßnahme, um das Attribut oder die Methode zu verbergen, aber es erleichtert das Ableiten: Eine Unterklasse kann die gleichen Namen für eigene weitere Attribute und Methoden verwenden wie ihre Oberklasse; sie werden dann zusätzlich angelegt, weil sie ja intern anders heißen. So verursachen sie keine Konflikte.

Doppelte Unterstriche sind vor allem in tiefen Klassenhierarchien mit Kindeskindeskinderklassen usw. hilfreich, in denen man den Überblick darüber verliert, welche Attribute und Methoden die Oberklassen und Oberoberklassen usw. haben, oder in denen die Gefahr besteht, dass später in einer Oberklasse ein Attribut oder eine Methode ergänzt wird, dessen/deren Namen es schon in einer der Unterklassen gibt.

Ein Beispiel:

class Beispiel:
    def __init__(self):
        self.__privates_attribut = 42

    def __private_methode(self):
        print('Diese Methode ist privat.')

    def zugriff_methode(self):
        self.__private_methode()

obj = Beispiel()
# Direkter Zugriff schlägt fehl:
# print(obj.__privates_attribut)   # AttributError
# obj.__private_methode()          # AttributError

# Zugriff über Namensmangling:
print(obj._Beispiel__privates_attribut)  # 42
obj._Beispiel__private_methode()         # Diese Methode ist privat.

Klassen-Attribute und -Methoden

Alle bisher erwähnten Attribute und Methoden haben sich jeweils auf eine Instanz, also auf ein Objekt der Klasse bezogen: Jede Instanz der Klasse besitzt eigene Werte für die Attribute und hat in den Methoden die Variable self, die auf die aktuelle Instanz verweist. In der Objektorientierung unterscheidet man zwischen solchen Instanz-Attributen und -Methoden, die spezifisch für eine Instanz (ein Objekt) einer Klasse sind, und Klassen-Attributen und -Methoden, die für die gesamte Klasse und alle ihre Instanzen gelten. Letztere heißen oft auch statisch.

In Python gibt es (anderes als in den meisten anderen Sprachen) eine Unterscheidung zwischen Klassenmethoden und statischen Methoden. Erstere können einfach auf Klassenattribute zugreifen, letztere nicht.

Ein Beispiel

In Python wird ein Klassen-Attribut in der Klassendefinition außerhalb aller Methoden definiert:

class Fahrzeug:
    # Klassen-Attribut
    anzahl_fahrzeuge = 0

    def __init__(self, marke, modell):
        # Instanz-Attribute
        self.marke = marke
        self.modell = modell
        # Erhöhen der Anzahl der Fahrzeuge bei jeder Instanziierung
        Fahrzeug.anzahl_fahrzeuge += 1

# Instanziiere Fahrzeuge
auto1 = Fahrzeug('Toyota', 'Corolla')
auto2 = Fahrzeug('Honda', 'Civic')

print(f'Anzahl der Fahrzeuge: {Fahrzeug.anzahl_fahrzeuge}')  # Ausgabe: 2

Eine Klassen-Methode wird mit dem Dekorator @classmethod und einem speziellen ersten Parameter cls (der die Klasse selbst repräsentiert) definiert:

class Fahrzeug:
    anzahl_fahrzeuge = 0

    def __init__(self, marke, modell):
        self.marke = marke
        self.modell = modell
        Fahrzeug.anzahl_fahrzeuge += 1

    @classmethod
    def get_anzahl_fahrzeuge(cls):
        return cls.anzahl_fahrzeuge

# Instanziiere Fahrzeuge
auto1 = Fahrzeug('Toyota', 'Corolla')
auto2 = Fahrzeug('Honda', 'Civic')

print(f'Anzahl der Fahrzeuge: {Fahrzeug.get_anzahl_fahrzeuge()}')  # Ausgabe: 2

In einer Klassenmethode hat also cls die Rolle des self in einer Instanzmethode. Eine Instanzmethode wird mittels einer konkreten Instanz aufgerufen wird, nach dem Muster auto1.tu_etwas(). Eine Klassenmethode wird dagegen über die Klasse selbst aufgerufen: Fahrzeug.tu_etwas().

Anders als eine Klassenmethode arbeitet eine statische Methode unabhängig von der Klasse und ihren Instanzen, hat also keinen Zugriff auf cls oder self. Sie wird mit dem Dekorator @staticmethod definiert und ebenfalls über die Klasse selbst aufgerufen, nicht über eine Instanz:

class Fahrzeug:
    anzahl_fahrzeuge = 0

    def __init__(self, marke, modell):
        self.marke = marke
        self.modell = modell
        Fahrzeug.anzahl_fahrzeuge += 1

    @classmethod
    def get_anzahl_fahrzeuge(cls):
        return cls.anzahl_fahrzeuge

    @staticmethod
    def beispiel_static_method():
        print('Dies ist eine statische Methode.')

# Aufruf der statischen Methode
Fahrzeug.beispiel_static_method()  # Ausgabe: Dies ist eine statische Methode.

Werte und Referenzen

In Python enthalten alle (alle!) Variablen Referenzen und nicht direkt Werte. Das ist anders als in C, C++, Java und vielen anderen Sprachen, in denen insbesondere Zahlenvariablen direkt Werte speichern, statt (etwa als Zeiger) auf Werte zu verweisen.

Dies bedeutet, dass Variablen in Python auf Speicherorte verweisen, an denen Daten gespeichert sind, anstatt die tatsächlichen Daten selbst zu speichern. Python arbeitet also so, als ob man in C ausschließlich Zeiger (Pointer) nutzen würde. Das kann für Überraschungen sorgen, weil man dasselbe Objekt über verschiedene Referenzen ansprechen und ändern kann.

Mehrere Referenzen auf ein Objekt

Mehrere Variablen können auf dasselbe Objekte verweisen. Dies bedeutet, dass, wenn Sie ein Objekt über dieser Variable ändern, diese Änderung ebenfalls über die anderen Variablen sichtbar ist:

liste1 = [1, 2, 3]
liste2 = liste1  # Es gibt weiterhin nur eine Liste!

liste2.append(4)

print(liste1)    # Ausgabe: [1, 2, 3, 4]
print(liste2)    # Ausgabe: [1, 2, 3, 4]

In diesem Beispiel verweisen sowohl liste1 als auch liste2 auf dasselbe Listenobjekt. Wenn Sie liste2 ändern, wird liste1 ebenfalls geändert, da beide Variablen auf dasselbe Objekt verweisen.

Verwirrungsgefahr

Wenn zwei Referenzen auf dieselbe Instanz einer Klasse zeigen, können Änderungen über eine Referenz unerwartete Auswirkungen darauf haben, was man unter der anderen Referenz sieht. Dies kann besonders verwirrend sein, wenn man nicht bewusst ist, dass beide Referenzen tatsächlich auf dasselbe Objekt im Speicher zeigen.

Stellen wir uns ein Szenario vor, bei dem wir eine Klasse Konto haben, die ein Bankkonto darstellt. Wir erstellen ein Kontoobjekt und verwenden zwei verschiedene Referenzen darauf.

class Konto:
    def __init__(self, inhaber: str, kontostand: float):
        self.inhaber = inhaber
        self.kontostand = kontostand

    def einzahlen(self, betrag: float):
        self.kontostand += betrag

    def abheben(self, betrag: float):
        if betrag <= self.kontostand:
            self.kontostand -= betrag
        else:
            print('Nicht genügend Guthaben.')

    def zeige_info(self):
        print(f'Inhaber: {self.inhaber}, Kontostand: {self.kontostand:.2f} EUR')

# Erstellen eines Konto-Objekts
konto1 = Konto('Alice', 1000.00)
# Erstellen einer zweiten Referenz auf dasselbe Konto-Objekt
konto2 = konto1

# Einzahlen über die erste Referenz
konto1.einzahlen(500.00)

# Anzeigen des Kontostands über beide Referenzen
konto1.zeige_info()  # Ausgabe: Inhaber: Alice, Kontostand: 1500.00 EUR
konto2.zeige_info()  # Ausgabe: Inhaber: Alice, Kontostand: 1500.00 EUR

# Abheben über die zweite Referenz
konto2.abheben(200.00)

# Anzeigen des Kontostands über beide Referenzen
konto1.zeige_info()  # Ausgabe: Inhaber: Alice, Kontostand: 1300.00 EUR
konto2.zeige_info()  # Ausgabe: Inhaber: Alice, Kontostand: 1300.00 EUR

Hier sind einige Szenarien, in denen diese Art von Verwirrung auftreten kann:

Unveränderbare Objekte

Unveränderbare (immutable) Python-Objekte wie Zahlen und Strings können nicht direkt geändert werden. Stattdessen erzeugt jede (scheinbare!) Modifikation eines unveränderbaren Objekts ein neues Objekt. Dadurch merkt man oft nicht, dass Python mit Referenzen arbeitet, da Änderungen scheinbar lokal auf die jeweilige Variable beschränkt bleiben.

zahl1 = 10
zahl2 = zahl1

zahl2 += 5

print(zahl1)  # Ausgabe: 10
print(zahl2)  # Ausgabe: 15

Hier verweisen zahl1 und zahl2 zunächst auf dasselbe Integer-Objekt 10. Wenn zahl2 jedoch geändert wird (zahl2 += 5), erzeugt Python ein neues Integer-Objekt 15 und zahl2 verweist nun auf dieses neue Objekt, während zahl1 weiterhin auf 10 verweist.

Strings sind ebenfalls unveränderbar. Jede Modifikation an einem String erzeugt ein neues String-Objekt.

string1 = 'Hallo'
string2 = string1

string2 += ', Welt!'

print(string1)  # Ausgabe: Hallo
print(string2)  # Ausgabe: Hallo, Welt!

Hier verweist string1 auf ein String-Objekt mit dem Inhalt Hallo und string2 auf dasselbe Objekt. Wenn string2 geändert wird, erzeugt Python ein neues String-Objekt Hallo, Welt! und string2 verweist nun auf dieses neue Objekt, während string1 weiterhin auf Hallo verweist.

Unveränderbare Objekte machen einige Arten an Fehlern unmöglich. Deshalb schreibt man oft auch eigene Klassen so, dass sie nie Veränderungen an einer einmal gebauten Instanz vornehmen.

Die Nullreferenz None

In Python ist None ein spezieller Wert, der verwendet wird, um das Fehlen eines konkreten Wertes zu kennzeichnen. (Pythons None entspricht in C und C++ der Nullzeiger.) Es ist ein einzelnes Objekt, das oft verwendet wird, um Variablen zu initialisieren oder anzuzeigen, dass eine Angabe fehlt oder dass eine Funktion nichts zurückgibt. Zum Beispiel gibt eine Funktion, die keinen return-Befehl enthält, automatisch None zurück. None ist auch nützlich, wenn Sie eine Variable anlegen wollen, die später einen Wert erhalten soll, aber vorerst leer ist.

Um ein häufiges Missverständnis zu verhindern: Eine Nullreferenz ist nicht etwas wie die Zahl 0 oder die leere Zeichenkette ''. Die Zahl 0 hat den Typ int und die leere Zeichenkette hat den Typ str, nicht den Typ None. Die Nullreferenz ist vielmehr die Abwesenheit von konkreten Informationen. 0 Euro zu verdienen, heißt zu wissen, was man verdient (nämlich nichts). Ein Passwort, das keine Zeichen enthält, ist ein konkretes (wenn auch schlechtes) Passwort.

Wenn eine Variable wahlweise den Wert None haben kann, können Sie dies mit einem Type Hint der Art float | None darstellen. Wenn eine Funktion nichts zurückgeben soll, kann dies mit -> None angegeben werden. Zum Beispiel:

def beispiel_function(wert: float | None) -> None:
    if wert is None:
        print('Kein Wert angegeben')
    else:
        print(f'Der Wert ist {wert}')
Das logische Gegenteil von wert is None ist wert is not None, was für C-Gewöhnte falsch aussieht. Die Schreibweisen wert == None bzw. wert != None funktionieren auch meist, falls wert nicht eine eigenwillig geschriebene Klasse hat.

Nullreferenzen wie None hat deren Erfinder Tony Hoare später als seinen Milliarden-Dollar-Fehler bezeichnet, da die Einführung von Nullreferenzen in Programmiersprachen zu einer Vielzahl von Programmfehlern führte, die in Summe Milliarden von Dollar gekosten hätten. Nullreferenzen verursachen häufig Programmabstürze und Sicherheitslücken, weil oft vergessen wird, ausreichend zu prüfen, ob eine Variable None ist, bevor man darauf zugreift. Obwohl Nullreferenzen in vielen Sprachen weit verbreitet sind, hat die Erkenntnis ihrer Gefahren zu Alternativen geführt, wie z.B. Option-Typen in Sprachen wie Rust und Kotlin, die sicherstellen, dass Nullwerte explizit behandelt werden müssen. In Python tragen gute Praktiken wie Type Hints und sorgfältige Prüfungen dazu bei, die Risiken zu mindern und robusteren Code zu schreiben.