Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Objektorientierte Programmierung

Heinrich-Heine-Universität Düsseldorf

Worum es geht. Klassen in Python — und vor allem die Stellen, an denen sie sich anders verhalten als in Java: kein Zugriffsschutz, Mehrfachvererbung, überladbare Operatoren, und zwei verschiedene Wege, ein Interface zu beschreiben.

Was Sie danach können. self sowie Klassen- und Instanzattribute auseinanderhalten; Operatoren über Dunder-Methoden überladen; entscheiden, ob eine abstrakte Basisklasse oder ein Protocol das richtige Mittel ist; und @property, @dataclass und Enum einsetzen und die Wahl begründen.

Die Java-Entsprechung in einem Satz. Eine ABC ist ein interface mit Vererbungszwang, ein Protocol ist ein interface, das man einer fremden Klasse nachträglich anlegen kann, ohne sie anzufassen — und private gibt es nicht, nur eine Konvention mit Unterstrich.

Eine neue Klasse wird mit class deklariert. Sie muss nicht von einer bestimmten Klasse erben, erbt aber implizit von object:

class c():
    pass

assert issubclass(c, object)
print(c, type(c))
# Output: <class '__main__.c'> <class 'type'>

x = c()
assert isinstance(x, c)
assert isinstance(x, object)
print(x, type(x))
# Output: <__main__.c object at 0x7fecd48ecd10> <class '__main__.c'>

Konstruktor und self

Wenn man von einer Klasse c eine Instanz anlegen möchte, so behandelt man c wie eine Methode, die eine Instanz zurück gibt. Der Aufruf c() ruft c.__init__() auf. So ein Konstruktor kann auch Argumente nehmen:

class c():
    def __init__(self, x : int):
        self.value = x

instance = c(23)
print(instance.value)
# Output: 23

Dabei ist das self in der Signatur eine Referenz auf die (neue) Instanz (wie this in Java). Der Aufruf des Konstruktors c(23) erforderte nur x, nicht self. Technisch gesehen ist eigentlich __new__ der Konstruktor und __init__ der Initialisator, aber praktisch schreibt man i.d.R. nur __init__-Methoden. Warum ist das so?

Während man in Java oft viele Konstruktoren mit verschiedenen Methodensignaturen (Typen der Parameter) verwendet, ist das in Python unüblich, denn Methoden werden über ihre Namen identifiziert. Zwei verschiedene Konstruktoren bewirken daher nicht das aus Java erwartete:

class c():
    def __init__(self, x : int):
        self.value = x
    def __init__(self, x : int, y : int):
        self.value = x/y

try:
    instance = c(23)
except TypeError as e:
    print(e)
    # Output: c.__init__() missing 1 required positional argument: 'y'

Stattdessen arbeitet man mit flexibler Methodensignatur:

class c():
    def __init__(self, x : int, y=None):
        if y is None:
            self.value = x
        else:
            self.value = x//y

print(c(42).value, c(121, 23).value)
# Output: 42 5

Und/oder man verwendet ein Factory-Design-Pattern; etwa wenn man eine Datenstruktur aus verschiedenen anderen herstellen kann:

class c():
    def __init__(self, n: int):
        self.value = n

    @staticmethod
    def fromPandas(df):
        return c(len(df))

    @staticmethod
    def fromNumpy(arr):
        return c(len(arr))

instance = c.fromNumpy([1, 2, 3])  # alles mit len() funktioniert hier (Duck Typing, s.u.)
print(instance.value)
# Output: 3

Konvention statt Schutz

Es gibt keine Möglichkeit, Methoden oder Attribute als privat oder anderweitig geschützt zu markieren. Es ist stets möglich, von außen auf alle Methoden und Attribute zuzugreifen. Um zu kommunizieren, was das Interface zur Nutzung einer Klasse und ihrer Instanzen ist, und was nur für die interne Implementierung gedacht war, gibt es die Konvention, einen einzelnen Underscore voranzustellen, um private Attribute und Methoden zu kennzeichnen.

class c():
    def _internal(self, x):
        return x
    def external(self, x):
        return self._internal(x)

print(c().external(23))
# Output: 23

#print(c()._internal(23)) # <-- das geht (leider) auch

Operatoren überladen

Will man z.B. eine Klasse definieren, deren Instanzen sich in irgendeinem Sinne addieren lassen, so kann man dafür die +-Syntax verwenden, indem man die __add__-Methode implementiert:

class c():
    def __init__(self, initialValue : int):
        self.value = initialValue

x, y = c(2), c(3)
try:
    print(x + y)
except TypeError as e:
    print(e)

# Output: unsupported operand type(s) for +: 'c' and 'c'
class c():
    def __init__(self, initialValue : int):
        self.value = initialValue
    def __add__(self, other):
        if isinstance(other, c):
            return c(self.value + other.value)
        else:
            raise TypeError("unsupported operand type(s) for +: 'c' and '%s'" % type(other).__name__)
    def __repr__(self):
        return "c("+repr(self.value)+")"

x, y, z = c(2), c(3), 4
print(x + y)
# Output: c(5)

try:
    print(x + z)
except TypeError as e:
    print(e)

# Output: unsupported operand type(s) for +: 'c' and 'int'

Eine vollständige Liste der Operatoren, welche mit Dunder-Methoden überladen werden, gibt es in der Sprachdokumentation.

Super

Wenn man eine Klasse von einer anderen erben lässt und eine Methode in der Unterklasse implementiert, die in der Oberklasse bereits definiert war, so muss man selbst explizit die Methode der Oberklasse aufrufen, wenn man das möchte - wie typischerweise in einem Konstruktor:

class s():
    def __init__(self, x : int):
        self.value = x

class c(s):
    def __init__(self, x : int):
        super().__init__(x)
        self.bigvalue = self.value * 2

Die Methode super geht durch die MRO, die Method Resolution Order. Siehe auch c.__mro__.

Klassen und Instanzen

Man kann nicht nur Instanzen mit Eigenschaften und Methoden versehen, sondern auch direkt die Klasse:

class c():
    classistvalue = "classy"
    def __init__(self, instancevalue):
        self.instancevalue = instancevalue

print(c.classistvalue) # Output: classy
c.classistvalue = "very classy"
ins = c("valuable and")
ins.classistvalue = "yet still classy"
print(ins.instancevalue, c.classistvalue) # Output: valuable and very classy

Vor der Zuweisung eines Werts auf ins.classistvalue gibt ins.classistvalue einfach c.classistvalue zurück, danach jedoch eine neue Instanzvariable, sogenanntes Shadowing.

Problematisch wird es, wenn eine Klassenmethode kein self nimmt, aber von einer Instanz aufgerufen wird (sodass dann self übergeben wird). Um klar festzulegen, ob es sich um eine statische Methode handelt, die weder self noch die Klasse als Argument bekommen soll, auch wenn sie von der Instanz aufgerufen wird, verwendet man den @staticmethod-Decorator.

Wenn man anstelle der Instanz als self lieber die Klasse als cls verwendern möchte, ist @classmethod der richtige Decorator:

class c():
    @classmethod
    def talkClassyNonsense(cls):
        print(type(cls), cls)
    
    @staticmethod
    def talkNonsense():
        print("no sense")
    
    def talkInstance(self):
        print(type(self), self)
    
    def talkFree():
        print("free sense")

instance = c()
instance.talkClassyNonsense() # Output: <class 'type'> <class '__main__.c'>
instance.talkNonsense() # Output: no sense
instance.talkInstance() # Output: <class '__main__.c'> <__main__.c object at 0x7f01fa8a3650>
instance.talkFree() # Output: TypeError: c.talkFree() takes 0 positional arguments but 1 was given

c.talkClassyNonsense() # Output: <class 'type'> <class '__main__.c'>
c.talkNonsense() # Output: no sense
c.talkInstance() # Output: TypeError: c.talkInstance() missing 1 required positional argument: 'self'
c.talkFree() # Output: free sense

Duck Typing

Wenn es läuft wie eine Ente und quakt wie eine Ente, dann ist es eine Ente. Zumindest für die Zwecke von allem, wo es nur um Lauf und Quaken geht. Nach dem Prinzip wird in Python häufig ein bestimmtes Argument nicht von einem bestimmten Typen erwartet, sondern es wird schlicht erwartet, dass bestimmte Methoden oder Eigenschaften implementiert sind (das Quaken und Laufen):

class c():
    def quack(self, x):
        return 2*x

class d():
    def quack(self, y):
        return 3*y

def quacker(duck, number):
    print(duck.__class__.__name__, "quacks", duck.quack(number))

quacker(c(), 5) # Output: c quacks 10
quacker(d(), 5) # Output: d quacks 15

So lässt sich auch nachträglich das Interface nachrüsten, im Extremfall mit sogenanntem Monkey Patching:

class boringclass():
    pass

def quacker(duck, number):
    print(duck.__class__.__name__, "quacks", duck.quack(number))

x = boringclass()
y = boringclass()

def cquack(self, x):
    return 2*x

boringclass.quack = cquack

quacker(x, 3) # Output: boringclass quacks 6
quacker(y, 3) # Output: boringclass quacks 6

def dquack(y):
    return 3*y

y.quack = dquack

quacker(x, 3) # Output: boringclass quacks 6
quacker(y, 3) # Output: boringclass quacks 9

Im vorangegangenen Code-Beispiel sehen wir zwei unterschiedliche Arten: Mit boringclass.quack = cquack haben wir die Klassendeklaration für alle Instanzen von boringclass verändert, also auch von y. Um nur der Instanz y ein quack zu verleihen, setzen wir y.quack.

ABCs

Abstract Base Classes sind ein Zugang, etwas wie Interfaces in Python zu verwenden. Damit lässt sich der Vertrag, den ein Objekt/Parameter/Typ einhalten soll, explizit spezifizieren. Wenn man eine abstrakte Klasse instanziiert, gibt es einen TypeError. Eine Klasse, die von einer abstrakten Klasse erbt und nicht alle abstractmethods implementiert, ist wieder abstrakt.

from abc import ABC, abstractmethod
class planarShape(ABC):
    @abstractmethod
    def surfaceArea(self):
        pass

class squareShape(planarShape):
    def __init__(self, sidelength):
        self.length = sidelength
    def surfaceArea(self):
        return self.length ** 2

Am ehesten begegnen uns ABCs in Form der Collections API mit den ABCs Callable, Hashable, Iterable, Iterator, Generator uvm. aber auch im Modul io sowie asyncio kommen ABCs zum Einsatz.

Protocols (strukturelle Typisierung)

ABCs beschreiben einen Vertrag nominal: Eine Klasse gehört dazu, weil sie erbt (oder per register nachgemeldet wird). Das setzt voraus, dass man die Klasse in der Hand hat. Genau das ist bei fremdem Code nicht der Fall — und beim Duck Typing oben hatten wir gar keinen aufgeschriebenen Vertrag, sondern nur die Hoffnung, dass quack existiert.

typing.Protocol schließt diese Lücke: Man schreibt auf, welche Methoden ein Objekt haben muss, und ein Type Checker prüft strukturell, ob ein übergebenes Objekt passt. Vererbung ist dafür nicht nötig.

from typing import Protocol

class Quackable(Protocol):
    def quack(self, x: int) -> int: ...

class Ente:                       # erbt *nicht* von Quackable
    def quack(self, x: int) -> int:
        return 2 * x

class Stein:
    pass

def quacker(duck: Quackable, number: int) -> None:
    print(duck.__class__.__name__, "quacks", duck.quack(number))

quacker(Ente(), 5)   # Output: Ente quacks 10
quacker(Stein(), 5)  # läuft auf einen AttributeError zu

Die letzte Zeile scheitert zur Laufzeit mit einem AttributeError — aber Mypy meldet sie schon vorher:

error: Argument 1 to "quacker" has incompatible type "Stein"; expected "Quackable"  [arg-type]

Das ist formalisiertes Duck Typing: Die drei Punkte ... im Protokoll sind ein echter Funktionskörper (Ellipsis), keine Auslassung im Skript — es gibt hier nichts zu implementieren, nur etwas zu behaupten.

Der praktische Gewinn zeigt sich bei fremdem Code: Man kann das Interface einer Bibliothek nachträglich beschreiben, ohne eine ihrer Zeilen anzufassen. Ein Protokoll lebt bei dem, der es braucht, nicht bei dem, der es erfüllt — man kann also für dieselbe Klasse mehrere schmale Protokolle formulieren, je nachdem, was die aufrufende Funktion wirklich benutzt. In Java entspricht dem am ehesten ein Interface, das man aber zur Definitionszeit der Klasse schon kennen und implements hinschreiben muss.

Braucht man die Prüfung zur Laufzeit, hilft @runtime_checkable:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Quackable(Protocol):
    def quack(self, x: int) -> int: ...

print(isinstance(Ente(), Quackable), isinstance(Stein(), Quackable))
# Output: True False

Achtung: isinstance prüft bei Protokollen nur, ob die Namen existieren — Signaturen und Typen werden zur Laufzeit nicht geprüft. Die eigentliche Arbeit macht der Type Checker.

Als Faustregel: ABC, wenn man die Hierarchie selbst baut und gemeinsamen Code vererben will; Protocol, wenn man fremde oder bereits existierende Typen beschreiben will.

Mehrfachvererbung

Im Gegensatz zu Java erlaubt Python die Mehrfachvererbung. Eine Klasse kann also von mehreren Elternklassen gleichzeitig erben. Dies wird häufig für sogenannte Mixins verwendet, um einer Klasse bestimmte Fähigkeiten hinzuzufügen, ohne eine tiefe Hierarchie aufzubauen.

Die Reihenfolge der Basisklassen in der Klammer ist dabei entscheidend. Sie bestimmt, in welcher Reihenfolge Python nach Methoden sucht (Method Resolution Order - MRO).

class quackable():
    def __init__(self, multiplier):
        self.multiplier = multiplier
    
    def quack(self, x):
        return self.multiplier * x

class quacklist(list, quackable):
    def __init__(self, multiplier):
        list.__init__(self)
        quackable.__init__(self, multiplier)

def quacker(duck, number):
    print(duck.__class__.__name__, "quacks", duck.quack(number))

x = quacklist(3)
x.append(7)
x.append(9)
print(x, type(x)) # Output: [7, 9] <class '__main__.quacklist'>

quacker(x, 5) # Output: quacklist quacks 15

Wenn man in der Klasse quackable z.B. auch __len__ implementiert, wird der Aufruf len(x) trotzdem die list-Implementierung von __len__ verwenden, denn wir haben in der Vererbung list vor quackable aufgeführt und so die method resolution order festgelegt.

Der Property-Decorator

Während in Java gern mit Gettern und Settern für Objekteigenschaften gearbeitet wird, um direkt vor/nach dem Setzen und Lesen direkt Code ausführen zu können, ist es in Python üblicher, direkt auf Variablen zuzugreifen. Um dennoch ein Getter/Setter-Pattern zu verwenden, kann man die Variable mit einem vorangestellten Underscore als privat markieren und Getter und Setter implementieren:

class c():
    def __init__(self, value=0):
        self._value = value
    
    def getValue(self):
        print("We give away our value:", self._value)
        return self._value
    
    def setValue(self, value):
        if value is not None and value > 0:
            self._value = value
        else:
            raise ValueError("We insist on positive values")

x = c()
x.setValue(23)
x.getValue()
# Output: We give away our value: 23

Das lässt sich ein kleines bisschen syntaktisch aufbessern, weil Python den Unterschied zwischen Eigenschaften und Methoden so schön verschwimmen lässt:

class c():
    def __init__(self, value=0):
        self._value = value
    
    @property
    def value(self):
        print("We give away our value:", self._value)
        return self._value
    
    @value.setter
    def value(self, value):
        if value is not None and value > 0:
            self._value = value
        else:
            raise ValueError("We insist on positive values")

x = c()
x.value = 23   # calls the setter
print(x.value) # calls the getter
# Output: We give away our value: 23
# 23

Dataclasses (Records)

Was der struct in C oder der RECORD in Pascal ist seit Python 3.7 die dataclass. Der Dekorator sorgt dafür, dass __init__ sowie eine lesbare Repräsentation mit __repr__ und der elementweise Vergleich in __eq__ implementiert werden, ohne das explizit selbst machen zu müssen:

from dataclasses import dataclass

@dataclass
class Gegenstand():
    name: str
    laenge: float

ding = Gegenstand("ding", 5.7)
dang = Gegenstand("zweiter", 23)
dong = Gegenstand(laenge=5.7, name="ding")
assert ding == dong

print(dang)
# Output: Gegenstand(name='zweiter', laenge=23)

Man kann auch explizit mit @dataclass(frozen = True) die Instanzen immutable machen. Das ist nützlich um z.B. Konfigurationsoptionen zu verwalten.

Enums (Aufzählungen)

Für eine feste, überschaubare Menge benannter Werte — Produktarten, Spielzustände, Himmelsrichtungen — benutzt man in Python keine Strings und keine int-Konstanten, sondern enum.Enum. Der Grund ist derselbe wie in Java: Ein Tippfehler in "maschinen" fällt erst zur Laufzeit auf (und oft gar nicht), ein Tippfehler in ProductType.MASCHINEN sofort mit einem AttributeError — und der Type Checker sieht ihn schon vorher.

from enum import Enum, auto

class ProductType(Enum):
    FOOD = auto()
    ENERGY = auto()
    MACHINERY = auto()

    def __str__(self):
        return self.name.title()

print(repr(ProductType.FOOD), ProductType.FOOD.name, ProductType.FOOD.value)
# Output: <ProductType.FOOD: 1> FOOD 1

print(f"Wir handeln mit {ProductType.MACHINERY}.")
# Output: Wir handeln mit Machinery.

auto() vergibt die Werte (hier 1, 2, 3) automatisch — man sollte sich auf sie nur verlassen, wenn sie eine Bedeutung haben. Die Mitglieder sind Singletons, deshalb vergleicht man sie mit is und kann sie als Dictionary-Schlüssel verwenden:

print(ProductType.FOOD is ProductType["FOOD"] is ProductType(1))
# Output: True

lager = {ProductType.FOOD: 12}
print([p.name for p in ProductType], lager[ProductType.FOOD])
# Output: ['FOOD', 'ENERGY', 'MACHINERY'] 12

Eine Enum-Klasse ist also selbst iterierbar, und ProductType["FOOD"] bzw. ProductType(1) sind die beiden Wege zurück — nützlich beim Einlesen von Spielständen oder Konfigurationsdateien. Weil __str__ hier überschrieben ist, erscheint in Ausgaben Machinery statt ProductType.MACHINERY; das ist reine Kosmetik und in der Wirtschaftssimulation genau so gemacht.

Drei Varianten begegnen einem häufig:

Enums arbeiten gut mit match zusammen und machen eine Verzweigung vollständig überprüfbar:

def beschreibe(p: ProductType) -> str:
    match p:
        case ProductType.FOOD:
            return "verderblich"
        case ProductType.ENERGY:
            return "sofort zu verbrauchen"
        case ProductType.MACHINERY:
            return "langlebig"

print(beschreibe(ProductType.ENERGY))
# Output: sofort zu verbrauchen

Mypy weiß, dass die drei Fälle die Enum vollständig abdecken, und akzeptiert die Funktion ohne else. Kommt später ein viertes Produkt hinzu, meldet es genau hier error: Missing return statement [return] — bei Strings als Konstanten wäre der vergessene Fall stillschweigend zu None geworden und erst beim Nutzer aufgefallen.

Zusammenfassung und Kontrollfragen

  1. Es gibt keinen Zugriffsschutz: Ein führender Unterstrich ist eine Mitteilung an Lesende, keine Sperre für den Interpreter.

  2. self ist ein gewöhnlicher erster Parameter, und Operatoren sind gewöhnliche Methoden mit festgelegten Namen — a + b ruft a.__add__(b) auf.

  3. Bei Mehrfachvererbung entscheidet die Reihenfolge der Basisklassen über die Method Resolution Order; super() folgt ihr, und __mro__ macht sie sichtbar.

  4. Für Interfaces gibt es zwei Wege: ABCs verlangen Vererbung (nominale Typisierung), Protocol prüft nur die vorhandene Struktur — Letzteres ist das Mittel für fremden Code, den man nicht ändern kann oder darf.

  5. @dataclass erzeugt __init__, __repr__ und __eq__; Enum macht aus verabredeten Zeichenketten Werte, die man nicht vertippen kann, und erlaubt einem Typechecker, einen vergessenen Fall zu melden.

Kontrollfragen

  1. Sie sollen prüfbar festhalten, welche Methoden Ihr Code von einer Klasse aus einer fremden Bibliothek erwartet — die Bibliothek dürfen Sie nicht ändern. ABC oder Protocol? Begründen Sie die Wahl.

  2. Warum ergibt c() + c() einen TypeError, solange die Klasse c nur __init__ definiert? Welche Methode ändert das, und wie heißt sie?

  3. Im Kapitel steht class quacklist(list, quackable). Was würde sich ändern, wenn dort class quacklist(quackable, list) stünde, und wo lässt sich die daraus folgende Suchreihenfolge ablesen?