Python & OOP: So geht die objektorientierte Programmierung

Seit Veröffentlichung von Version 3 setzt Python voll auf objektorientierte Programmierung (OOP). Die Sprache folgt der Design-Philosophie „everything is an object“, zu Deutsch „jedes Ding ist ein Objekt.“

Anders als in Java, C++ und Python 2.x findet keine Unterscheidung zwischen primitiven Typen und Objekten statt. Zahlen, Strings und Listen, sogar Funktionen und Klassen sind in Python allesamt Objekte.

Im Vergleich mit anderen Sprachen zeichnet sich Pythons klassenbasierte OOP durch hohe Flexibilität und wenige feste Beschränkungen aus. Damit steht die Sprache als extremes Gegenbeispiel zu Java, deren OOP-System als ausgesprochen rigide gilt. Wir erklären anschaulich, wie in Python objektorientierte Programmierung funktioniert.

Wozu dient in Python die objektorientierte Programmierung?

Die objektorientierte Programmierung ist eine Form der imperativen Programmierung. Objekte verbinden Daten und Funktionalität. Ein Objekt kapselt seinen internen Zustand; Der Zugriff erfolgt über eine öffentliche Schnittstelle, das sogenannte Interface des Objekts. Das Interface eines Objekts ist definiert durch seine Methoden. Objekte interagieren miteinander über Nachrichten, welche über Aufrufe der Methoden übergeben werden.

Tipp

Lesen Sie zum besseren Verständnis des Hintergrunds unsere Artikel „Was ist OOP“, „Programmierparadigmen“ und „Python-Tutorial“.

In Python mit OOP Objekte kapseln

Betrachten wie an einem Beispiel, wie sich in Python mit OOP Objekte kapseln lassen. Nehmen wir an, wir schreiben Code für eine Küche, Bar, oder ein Labor. Wir modellieren Behälter, wie Flaschen, Gläser, Tassen, etc. Allesamt Dinge, welche ein Volumen haben und sich befüllen lassen. Wir nennen eine Kategorie von Dingen eine „Klasse“.

Die Objekte, welche Behälter repräsentieren, haben einen internen Zustand, welcher sich ändern lässt. Behälter lassen sich befüllen, ausleeren, und so weiter. Sofern mit einem Verschluss versehen, können wir Behälter öffnen und schließen. Jedoch ist es logisch nicht möglich, das Volumen eines Behälters nachträglich zu verändern. Es ist naheliegend, verschiedene Überlegungen in Bezug auf den Zustand eines Behälters anzustellen, z.B.:

  • „Ist das Glas voll?“
  • „Was ist das Volumen der Flasche?“
  • „Hat der Behälter einen Deckel?“

Ferner macht es Sinn, Objekte miteinander interagieren zu lassen. Beispielsweise sollte es möglich sein, den Inhalt eines Glases in eine Flasche umzufüllen. Schauen wir uns zunächst an, wie die Veränderung des internen Zustands eines Objekts in Python mit objektorientierter Programmierung funktioniert. Die dargestellten Änderungen des Zustands bzw. Fragen über diesen, sind als Methoden-Aufrufe realisiert:

# create an empty cup with given capacity
cup = Container(400)
assert cup.volume() == 400
assert not cup.is_full()
# add some water to the cup
cup.add('Water', 250)
assert cup.volume_filled() == 250
# add more water, filling the cup
cup.add('Water', 150)
assert cup.is_full()

In Python mit OOP Typen definieren

Datentypen sind ein grundlegendes Konzept in der Programmierung. Unterschiedliche Daten lassen sich auf verschiedene Art und Weise verwenden; Zahlen werden durch arithmetische Operationen verarbeitet, Zeichenketten („Strings“) lassen sich durchsuchen:

# addition works for two numbers
39 + 3
# we can search for a letter inside a string
'y' in 'Python'

Versuche, eine Zahl und einen String zu addieren, bzw. innerhalb einer Zahl zu suchen brechen mit einem Typfehler ab:

# addition doesn't work for a number and a string
42 + 'a'
# cannot search for a letter inside a number
'y' in 42

Pythons eingebaute Typen sind abstrakt; eine Zahl kann alles mögliche repräsentieren: Distanz, Zeit, Geld. Die Bedeutung des Werts ist nur durch den Variablennamen gegeben:

# are we talking about distance, time?
x = 51

Was jedoch, wenn wir spezialisierte Konzepte modellieren möchten? Auch dieses Ziel erreichen wir in Python mit objektorientierter Programmierung. Objekte sind Datenstrukturen mit identifizierbarem Typ, welcher sich mit der eingebauten type()-Funktion anzeigen lässt:

# class 'str'
type('Python')
# class 'tuple'
type(('Walter', 'White'))

In Python mit objektorientierter Programmierung Abstraktionen erschaffen

In der Programmierung kommen Abstraktionen zum Einsatz, um Komplexität zu verbergen. Dies erlaubt Programmierenden, auf einer höheren Ebene zu operieren. Beispielshalber ist die Frage „ist das Glas voll?“ äquivalent zur Frage „ist das Volumen des Inhaltes des Glases gleich dem Volumen des Glases?“ Die erste, abstraktere Version ist kürzer und prägnanter und damit vorzuziehen. Abstraktionen erlauben, komplexere Systeme zu erschaffen und zu überblicken:

# instantiate an empty glass
glass = Container(250)
# add water to the glass
glass.add('Water', 250)
# is the glass full?
assert glass.is_full()
# a longer way to ask the same question
assert glass.volume_filled() == glass.volume()

In Python lassen sich mit OOP abstrakte Konzepte auf neue Ideen übertragen. Veranschaulichen wir uns das am Beispiel des Python Additions-Operators. Das Pluszeichen verknüpft zwei Zahlen, lässt sich jedoch auch auf Listen anwenden und fügt dann deren Inhalte zusammen:

assert 42 + 9 == 51
assert ['Jack', 'John'] + ['Jim'] == ['Jack', 'John', 'Jim']

Es ist naheliegend, das Konzept der Addition auf unser Modell zu übertragen. Wir definieren einen Additions-Operator für Behälter. Dies erlaubt uns, Code zu schreiben, welcher sich fast wie natürliche Sprache liest. Die Implementation erklären wir weiter unten, hier zunächst ein anschauliches Beispiel der Anwendung:

# pitcher with 1000 ml capacity
pitcher = Container(1000)
# glass with 250 ml capacity
glass = Container(250)
# fill glass with water
glass.fill('Water')
# transfer the content from the glass to the pitcher
pitcher += glass
# pitcher now contains water from glass
assert pitcher.volume_filled() == 250
# glass is empty
assert glass.is_empty()

Wie funktioniert in Python objektorientierte Programmierung?

Objekte vereinen Daten und Funktionalität, welche beide als Attribute bezeichnet werden. Anders als Java, PHP und C++ bietet Pythons OOP keine Schlüsselwörter wie private und protected, um den Zugriff auf Attribute zu beschränken. Stattdessen kommt eine Konvention zum Einsatz: Mit einem Unterstrich beginnende Attribute gelten als nicht-öffentlich. Dabei kann es sich um Daten-Attribute nach dem Schema _internal_attr handeln, sowie um Methoden nach dem Schema _internal_method().

Methoden werden in Python mit der Variable self als ersten Parameter definiert. Alle Zugriffe auf Objekt-Attribute vom Innern des Objekts aus erfolgen über eine Referenz auf self. Self fungiert in Python als Platzhalter für eine konkrete Instanz und spielt damit die Rolle, welche das this-Schlüsselwort in Java, PHP, JavaScript und C++ übernimmt.

Zusammengenommen mit der zuvor erklärten Konvention ergibt sich ein einfaches Muster zur Kapselung: Der Zugriff auf ein internes Attribut als Referenz self._internal ist in Ordnung, da dieser im Inneren des Objekts stattfindet. Zugriffe von außen im Stil von obj._internal verstoßen gegen die Kapselung und sollten vermieden werden:

class ExampleObject:
  def public_method(self):
    self._internal = 'changed from inside method'
# instantiate object
obj = ExampleObject()
# this is fine
obj.public_method()
assert obj._internal == 'changed from inside method'
# works, but not a good idea
obj._internal = 'changed from outside'

Klassen

Eine Klasse fungiert als Vorlage für Objekte. Man sagt ein Objekt wird aus Klassen instanziiert, also der Vorlage entsprechend erzeugt. Der Konvention folgend beginnen nutzerdefinierte Klassennamen mit einem Großbuchstaben.

Anders als in Java, C++, PHP und JavaScript existiert in Python-OOP kein new-Schlüsselwort. Stattdessen wird der Klassenname als Funktion aufgerufen und dient als Konstruktor, welcher eine neue Instanz liefert. Implizit ruft der Konstruktor die Initialisierungsfunktion __init__() auf, welche Objekt-Daten initialisiert.

Schauen wir uns die bisher erwähnten Muster an einem Codebeispiel an. Wir modellieren das Konzept eines Behälters als Klasse mit Namen Container und definieren Methoden für wichtige Interaktionen:

Methode Erklärung
__init__ Initialisiert neuen Behälter mit Startwerten.
__repr__ Gibt Zustand des Behälters als Text aus.
volume Gibt Fassungsvermögen des Behälters aus.
volume_filled Gibt Füllzustand des Behälters aus.
volume_available Gibt verbleibenden Raum des Behälters aus.
is_empty Gibt an, ob der Behälter leer ist.
is_full Gibt an, ob der Behälter voll ist.
empty Leert den Behälter und gibt Inhalt zurück.
_add Interne Methode, welche eine Substanz hinzufügt, ohne Checks vorzunehmen.
add Öffentliche Methode, welche angegebene Menge Substanz hinzufügt, sofern Raum vorhanden ist.
fill Füllt den verbleibenden Raum des Behälters mit einer Substanz.
pour_into Füllt den Inhalt des Behälters komplett in einen anderen Behälter um.
__add__ Implementiert Additions-Operator für Behälter; greift auf pour_into-Methode zurück.

Hier der tatsächliche Code der Container-Klasse. Nachdem Sie diesen in ihrem lokalen Python-REPL ausführen, können Sie die weiteren Code-Beispiel des Artikels ausprobieren:

class Container:
  def __init__(self, volume):
    # volume in ml
    self._volume = volume
    # start out with empty container
    self._contents = {}
  
  def __repr__(self):
    """
    Textual representation of container
    """
    repr = f"{self._volume} ml Container with contents {self._contents}"
    return repr
  
  def volume(self):
    """
    Volume getter
    """
    return self._volume
  
  def is_empty(self):
    """
    Container is empty if it has no contents
    """
    return self._contents == {}
  
  def is_full(self):
    """
    Container is full if volume of contents equals capacity
    """
    return self.volume_filled() == self.volume()
  
  def volume_filled(self):
    """
    Calculate sum of volumes of contents
    """
    return sum(self._contents.values())
  
  def volume_available(self):
    """
    Calculate available volume
    """
    return self.volume() - self.volume_filled()
  
  def empty(self):
    """
    Empty the container, returning its contents
    """
    contents = self._contents.copy()
    self._contents.clear()
    return contents
  
  def _add(self, substance, volume):
    """
    Internal method to add a new substance / add more of an existing substance
    """
    # update volume of existing substance
    if substance in self._contents:
      self._contents[substance] += volume
    # or add new substance
    else:
      self._contents[substance] = volume
  
  def add(self, substance, volume):
    """
    Public method to add a substance, possibly returning left over
    """
    if self.is_full():
      raise Exception("Cannot add to full container")
    # we can fit all of the substance
    if self.volume_filled() + volume <= self.volume():
      self._add(substance, volume)
      return self
    # we can fit part of the substance, returning the left over
    else:
      leftover = volume - self.volume_available()
      self._add(substance, volume - leftover)
      return {substance: leftover}
  
  def fill(self, substance):
    """
    Fill the container with a substance
    """
    if self.is_full():
      raise Exception("Cannot fill full container")
    self._add(substance, self.volume_available())
    return self
  
  def pour_into(self, other_container):
    """
    Transfer contents of container to another container
    """
    if other_container.volume_available() < self.volume_filled():
      raise Exception("Not enough space")
    # get the contents by emptying container
    contents = self.empty()
    # add contents to other container
    for substance, volume in contents.items():
      other_container.add(substance, volume)
    return other_container
  
  def __add__(self, other_container):
    """
    Implement addition for containers:
    `container_a + container_b` <=> `container_b.pour_into(container_a)`
    """
    other_container.pour_into(self)
    return self

Spielen wir ein paar Beispiele mit unserer Behälter-Implementation durch. Wir instanziieren ein Glas und füllen dieses mit Wasser. Wie zu erwarten, ist das Glas danach voll:

glass = Container(300)
glass.fill('Water')
assert glass.is_full()

Im nächsten Schritt entleeren wir das Glas und erhalten die davor enthaltene Menge Wasser zurück. Unsere Implementation scheint zu funktionieren, das Glas ist danach leer:

contents = glass.empty()
assert contents == {'Water': 300}
assert glass.is_empty()

Ein etwas anspruchsvolleres Beispiel. Wir mischen in einem Pitcher Wein und Orangensaft zusammen. Dazu erzeugen wir zunächst die benötigten Behälter und füllen zwei davon mit den Zutaten:

pitcher = Container(1500)
bottle = Container(700)
carton = Container(500)
# fill ingredients
bottle.fill('Red wine')
carton.fill('Orange juice')

Im Anschluss nutzen wir den Additions-Zuweisungs-Operator +=, um die Inhalte der beiden gefüllten Behälter in den Pitcher umzufüllen.

# pour ingredients into pitcher
pitcher += bottle
pitcher += carton
# check that everything worked
assert pitcher.volume_filled() == 1200
assert bottle.is_empty() and carton.is_empty()

Das funktioniert, weil unsere Container-Klasse die __add__()-Methode implementiert. Hinter den Kulissen wird die Zuweisung pitcher += bottle umgeformt zu pitcher = pitcher + bottle. Ferner wird pitcher + bottle von Python in den Methoden-Aufruf pitcher.__add__(bottle) übersetzt. Unsere __add__()-Methode liefert den Receiver zurück, in diesem Falle pitcher, so dass die Zuweisung funktioniert.

Statische Attribute

Bisher haben wir gesehen, wie sich auf die Attribute von Objekten zugreifen lässt: von außen über die öffentlichen Methoden, innerhalb der Methoden über eine Referenz auf self. Der interne Zustand der Objekte wird über Daten-Attribute realisiert, welche dem jeweiligen Objekt gehören. Auch die Methoden eines Objekts sind an eine spezifische Instanz gebunden. Es gibt jedoch auch Attribute, welche Klassen gehören. Das ergibt Sinn, denn Klassen sind in Python ebenfalls Objekte.

Klassen-Attribute werden auch als „statische“ Attribute bezeichnet, da sie bereits vor der Instanziierung eines Objekts existieren. Dabei kann es sich sowohl um Daten-Attribute als auch Methoden handeln. Das ist nützlich für Konstanten, welche für alle Instanzen einer Klasse gleich sind, sowie Methoden, welche nicht auf self operieren. Häufig werden Umrechnungsroutinen als statische Methoden implementiert.

Anders als Sprachen wie Java und C++, kennt Python kein static-Schlüsselwort, um explizit zwischen Objekt-Attributen und Klassen-Attributen zu unterscheiden. Stattdessen kommt ein Dekorator namens @staticmethod zum Einsatz. Schauen wir uns beispielshalber an, wie eine statische Methode für unsere Container-Klasse aussehen könnte. Wir implementieren eine Umrechnungsroutine, um Milliliter in Fluid Ounces zu konvertieren:

  # inside of class `Container`
  ...
  @staticmethod
  def floz_from_ml(ml):
    return ml * 0.0338140227

Zugriffe auf statische Attribute erfolgen wie gewohnt über eine Attribut-Referenz mit Punkt-Notation nach dem Schema obj.attr. Der Unterschied ist lediglich, dass links vom Punkt der Klassenname steht: ClassName.static_method(). Dies ist logisch konsistent, denn in der objektorientierten Programmierung mit Python sind auch Klassen Objekte. Um die Umrechnungsroutine unserer Container-Klasse aufzurufen, schreiben wir also:

floz = Container.floz_from_ml(1000)
assert floz == 33.8140227

Interfaces

Als „Interface“, zu Deutsch „Schnittstelle“ wird die Sammlung aller öffentlicher Methoden eines Objekts bezeichnet. Das Interface definiert und dokumentiert das Verhalten eines Objekts und dient als API. Anders als C++ kennt Python keine separaten Ebenen für Interface (Header-Dateien) und Implementation. Auch gibt es, anders als in Java und PHP, kein explizites interface-Schlüsselwort. In diesen Sprachen enthalten Interfaces Methoden-Signaturen und dienen als Beschreibung zusammenhängender Funktionalität.

Da in Python die Information, über welche Methoden ein Objekt verfügt und aus welcher Klasse es instanziiert wurde dynamisch zur Laufzeit bestimmt wird, benötigt die Sprache keine expliziten Interfaces. Stattdessen kommt in Python-OOP das Prinzip des „Duck Typing“ zum Tragen:

Zitat

„If it walks like a duck and it quacks like a duck, then it must be a duck“ — Quelle: docs.python.org/3/glossary.html


Übersetzung: „Wenn es läuft wie eine Ente und quakt wie eine Ente, dann muss es sich um eine Ente handeln.“ (übersetzt von IONOS)

Was ist nun mit Duck Typing gemeint? Kurz gesagt lässt sich ein Python-Objekt einer Klasse wie ein Objekt einer anderen Klasse benutzen, solange es die benötigten Methoden enthält. Anschaulich stellt man sich eine Entenattrappe vor: diese gibt Entengeräusche von sich, schwimmt wie eine Ente und wird von tatsächlichen Enten als Ente wahrgenommen.

Vererbung

Wie in den meisten objektorientierten Sprachen kennt auch Pythons OOP das Konzept der Vererbung: eine Klasse lässt sich als Spezialisierung einer Eltern-Klasse definieren. Durch Fortsetzen des Prozesses ergibt sich eine baumartige Klassen-Hierarchie, mit der vordefinierten Klasse Object als Wurzel. So wie C++ (aber anders als Java und PHP) erlaubt Python die mehrfache Vererbung: eine Klasse kann von mehreren Eltern-Klassen ableiten.

Die mehrfache Vererbung lässt sich flexibel einsetzen. Unter anderem kann man so die aus Ruby bekannten „Mixins“ bzw. PHP‘s „Traits“ realisieren. Auch die aus Java bekannte Aufspaltung von Funktionalität in Interfaces und abstrakte Klassen lässt sich in Python durch mehrfache Vererbung ersetzen.

Schauen wir uns an unserem Behälter-Beispiel an, wie die mehrfache Vererbung in Python funktioniert. Manche Behälter lassen sich mit einem Verschluss versehen, und wir möchten unsere Container-Klasse dahingehend spezialisieren. Dazu definieren wir eine neue Klasse SealableContainer, welche von Container erbt. Ferner definieren wir eine neue Klasse Sealable, welche Methoden zum Anbringen und Entfernen eines Verschlusses enthält. Da die Sealable-Klasse lediglich dazu dient, eine andere Klasse mit weiteren Methoden-Implementationen zu versorgen, handelt es sich um ein „Mixin“:

class Sealable:
  """
  Implementation needs to:
  - initialize `self._seal`
  """
  def is_sealed(self):
    return self._seal is not None
  
  def is_open(self):
    return not self.is_sealed()
  
  def is_closed(self):
    return not self.is_open()
  
  def open(self):
    """
    Opening removes and returns the seal
    """
    seal = self._seal
    self._seal = None
    return seal
  
  def seal_with(self, seal):
    """
    Closing attaches the seal and returns the Sealable
    """
    self._seal = seal
    return self

Unser SealableContainer erbt von der Container-Klasse und dem Sealable-Mixin. Wir überschreiben die __init__()-Methode und definieren zwei neue Parameter, welche ermöglichen, Inhalte und Verschluss des SealableContainer bei der Instanziierung zu setzen. Dies ist notwendig, um verschlossene Behälter mit Inhalten zu erzeugen. Innerhalb der __init__()-Methode rufen wir über super() die Initialisierung der Eltern-Klasse auf:

class SealableContainer(Container, Sealable):
  """
  Start out with empty, open container
  """
  def __init__(self, volume, contents = {}, seal = None):
    # initialize `Container`
    super().__init__(volume)
    # initialize contents
    self._contents = contents
    # initialize `self._seal`
    self._seal = seal
  
  def __repr__(self):
    """
    Append 'open' / 'closed' to textual container representation
    """
    state = "Open" if self.is_open() else "Closed"
    repr = f"{state} {super().__repr__()}"
    return repr
  
  def empty(self):
    """
    Only open container can be emptied
    """
    if self.is_open():
      return super().empty()
    else:
      raise Exception("Cannot empty sealed container")
  
  def _add(self, substance, volume):
    """
    Only open container can have its contents modified
    """
    if self.is_open():
      super()._add(substance, volume)
    else:
      raise Exception("Cannot add to sealed container")

Ähnlich wie bei der __init__()-Methode überschreiben wir gezielt weitere Methoden, um unseren SealableContainer vom unverschlossenen Behälter zu differenzieren. Wir überschreiben __repr__(), so dass der Zustand offen / verschlossen ebenfalls ausgegeben wird. Ferner überschreiben wir die empty()- und _add()-Methoden, welche nur bei geöffnetem Behälter Sinn ergeben. So erzwingen wir, dass ein verschlossener Behälter vor dem Entleeren oder Befüllen geöffnet wird. Wiederum setzen wir super() ein, um auf die bestehende Funktionalität der Elternklasse zuzugreifen.

Spielen wir ein Beispiel durch: Wir möchten einen Cuba Libre mixen. Dazu benötigen wir ein Glas, eine kleine Flasche Cola und ein Shot-Glas mit 20cl Rum:

glass = Container(330)
cola_bottle = SealableContainer(250, contents = {'Cola': 250}, seal = 'Bottlecap')
shot_glass = Container(40)
shot_glass.add('Rum', 20)

Wir geben etwas Eis in da Glas und fügen den Rum hinzu. Da die Cola-Flasche verschlossen ist, öffnen wir sie zunächst und gießen dann den Inhalt in das Glas:

glass.add('Ice', 50)
# add rum
glass += shot_glass
# open cola bottle
if cola_bottle.is_closed():
  cola_bottle.open()
# pour cola into glass
glass += cola_bottle