Zum Inhalt springen

cat posts/dunder-methoden-datenmodell.md

Das Datenmodell: Dunder-Methoden verstehen

Warum len(x), x + y, x[index] und for element in x funktionieren: Mit Dunder-Methoden bindest Du eigene Klassen in Pythons Datenmodell ein.

In Teil 7 der Python-Serie ist mit __str__ eine Methode aufgetaucht, deren Name mit jeweils zwei Unterstrichen beginnt und endet.

Solche Namen werden häufig Dunder Methods genannt – kurz für double underscore. In der Python-Dokumentation heißen sie Special Methods.

Hinter erstaunlich vielen vertrauten Operationen steckt eine davon:

len(objekt)
objekt[index]
wert in objekt
objekt_1 + objekt_2
objekt_1 == objekt_2

for element in objekt:
    ...

print(objekt)

Indem eine Klasse die passenden Special Methods implementiert, bindet sie sich in Pythons Datenmodell ein. Eigene Objekte können dadurch dieselbe Syntax unterstützen wie eingebaute Strings, Zahlen und Collections.

Der interessante Punkt ist dabei nicht, möglichst viele kryptische __irgendwas__-Methoden zu kennen. Es geht darum zu verstehen, wie Python seine Syntax mit Objekten verbindet.

Das Datenmodell besteht aus vielen kleinen Protokollen

Es gibt keine große Schnittstelle, die jede Python-Klasse vollständig implementieren müsste.

Stattdessen besteht das Datenmodell aus vielen kleineren Protokollen.

Eine Klasse mit:

__len__

kann eine Länge bereitstellen.

Mit:

__iter__

kann sie iterierbar werden.

Mit:

__enter__
__exit__

kann sie als synchroner Context Manager in einem with-Block verwendet werden.

Und eine Klasse mit:

__call__

kann sich wie eine Funktion aufrufen lassen.

Eine Klasse implementiert nur die Protokolle, die zu ihrer Bedeutung passen.

Python-Syntax und die zugehörigen Special Methods

Viele alltägliche Operationen lassen sich grob so zuordnen:

Du schreibst Beteiligte Special Methods
len(x) __len__
bool(x) __bool__, sonst __len__
x[index] __getitem__
x[index] = wert __setitem__
del x[index] __delitem__
element in x __contains__, mit Fallbacks
for element in x __iter__, mit altem Sequence-Fallback
next(iterator) __next__
reversed(x) __reversed__, sonst Sequence Protocol
x + y __add__, gegebenenfalls __radd__
x += y __iadd__, sonst binärer Operator-Fallback
x == y __eq__ mit Vergleichs-Fallbacks
x < y __lt__ beziehungsweise reflektiert __gt__
hash(x) __hash__
repr(x) __repr__
str(x) __str__
f"{x:...}" __format__
x() __call__
with x: __enter__ und __exit__

Die Tabelle ist absichtlich als Zuordnung und nicht als wörtliche Übersetzung zu verstehen.

Python ersetzt beispielsweise:

len(x)

nicht einfach durch normalen Anwendungscode wie:

x.__len__()

Die Regeln für Special Methods gehen tiefer.

Special Methods werden implizit auf dem Typ gesucht

Bei vielen impliziten Operationen sucht Python die Special Method auf dem Typ des Objekts und nicht wie ein gewöhnliches Attribut zuerst auf der Instanz.

Der Ausdruck:

len(objekt)

entspricht konzeptionell eher:

type(objekt).__len__(objekt)

als einem gewöhnlichen:

objekt.__len__()

Das lässt sich leicht zeigen:

class Beutel:
    pass


beutel = Beutel()
beutel.__len__ = lambda: 5

print(beutel.__len__())

Ausgabe:

5

Die Instanz besitzt also tatsächlich ein Attribut namens __len__.

Trotzdem funktioniert:

print(len(beutel))

nicht:

TypeError: object of type 'Beutel' has no len()

Damit len(beutel) funktioniert, muss die Special Method auf der Klasse existieren:

class Beutel:
    def __len__(self):
        return 5


beutel = Beutel()

print(len(beutel))

Ausgabe:

5

Dieses spezielle Lookup-Verhalten gilt für implizit aufgerufene Special Methods und ermöglicht dem Interpreter unter anderem Optimierungen.

Für normalen Code reicht eine einfache Regel:

Special Methods gehören in die Klasse. Verwendet werden anschließend die normalen Python-Operationen.

Special Methods möglichst nicht direkt aufrufen

Auch wenn das hier technisch funktioniert:

anzahl = inventar.__len__()

solltest Du normalerweise schreiben:

anzahl = len(inventar)

Dasselbe gilt für:

repr(objekt)
str(objekt)
element in objekt
objekt_1 + objekt_2

statt:

objekt.__repr__()
objekt.__str__()
objekt.__contains__(element)
objekt_1.__add__(objekt_2)

Die normale Python-Syntax ist nicht nur lesbarer. Sie lässt Python auch die vollständigen Regeln des Datenmodells anwenden, etwa Fallbacks und reflektierte Operatoren.

Special Methods implementieren das Protokoll. Anwendungscode benutzt das Protokoll.

Ein Inventar als eigener Container

Im Dungeon liegt das Inventar bisher als normale Liste im Spielerobjekt:

self.inventar = []

Das funktioniert, gibt dem restlichen Programm aber direkten Zugriff auf die interne Datenstruktur:

spieler.inventar.append(gegenstand)
spieler.inventar.clear()
spieler.inventar = "keine Liste mehr"

Wir bauen deshalb eine eigene Klasse Inventar.

Intern verwenden wir weiterhin eine Liste, stellen nach außen aber nur die Operationen bereit, die für ein Inventar sinnvoll sind.

Als Gegenstand verwenden wir die Dataclass aus dem Dataclasses-Artikel:

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class Gegenstand:
    name: str
    beschreibung: str = ""

    def __str__(self):
        return self.name

Die erste Inventar-Version:

class Inventar:
    def __init__(self, gegenstaende=None):
        self._gegenstaende = (
            list(gegenstaende)
            if gegenstaende is not None
            else []
        )

    def hinzufuegen(self, gegenstand):
        self._gegenstaende.append(gegenstand)

    def entfernen(self, gegenstand):
        self._gegenstaende.remove(gegenstand)

Der führende Unterstrich in:

_gegenstaende

signalisiert, dass dieses Attribut ein internes Implementierungsdetail ist.

Python erzwingt diese Privatheit nicht. Nutzer der Klasse sollen aber normalerweise über ihre öffentliche Schnittstelle arbeiten.

__len__: len(inventar) ermöglichen

Mit __len__ bekommt unser Inventar eine Länge:

class Inventar:
    # ...

    def __len__(self):
        return len(self._gegenstaende)

Nun funktioniert:

inventar = Inventar()

print(len(inventar))

inventar.hinzufuegen(Gegenstand("Fackel"))

print(len(inventar))

Ausgabe:

0
1

__len__ muss eine nicht negative Ganzzahl zurückgeben.

Diese Implementierung ist deshalb ungültig:

def __len__(self):
    return -1

Ein:

len(objekt)

führt damit zu einem ValueError.

Auch ein völlig anderer Rückgabetyp ist nicht erlaubt.

Truthiness: __bool__ und __len__

Objekte können in Bedingungen direkt auf ihren Wahrheitswert geprüft werden:

if inventar:
    ...

Dafür gibt es __bool__:

def __bool__(self):
    return bool(self._gegenstaende)

Für unseren Container brauchen wir die Methode aber gar nicht.

Fehlt __bool__, verwendet Python __len__, falls diese Methode existiert:

inventar = Inventar()

if inventar:
    print("Du trägst etwas bei Dir.")
else:
    print("Dein Inventar ist leer.")

Ausgabe:

Dein Inventar ist leer.

Nach:

inventar.hinzufuegen(Gegenstand("Fackel"))

gilt das Inventar als truthy.

Die Reihenfolge lautet:

  1. __bool__, falls vorhanden
  2. andernfalls __len__
  3. fehlen beide, gilt das Objekt grundsätzlich als truthy

Für Collection-artige Klassen ergibt sich die natürliche Bedeutung von Truthiness deshalb häufig schon aus __len__.

__iter__: das Inventar durchlaufen

Damit wir schreiben können:

for gegenstand in inventar:
    ...

implementieren wir __iter__:

class Inventar:
    # ...

    def __iter__(self):
        return iter(self._gegenstaende)

Nun funktioniert:

inventar = Inventar(
    [
        Gegenstand("Fackel"),
        Gegenstand("Brot"),
        Gegenstand("Schlüssel"),
    ]
)

for gegenstand in inventar:
    print(gegenstand)

Ausgabe:

Fackel
Brot
Schlüssel

Die eingebaute Funktion:

iter(self._gegenstaende)

erzeugt einen Iterator über unsere interne Liste.

Das Inventar ist damit ein Iterable, aber nicht selbst der Iterator.

Diese Trennung ist für Container normalerweise genau richtig.

Iterable und Iterator sind nicht dasselbe

Ein Iterable kann über iter(...) einen Iterator bereitstellen.

Ein Iterator benötigt dagegen insbesondere:

__iter__()
__next__()

Sein __iter__ liefert normalerweise sich selbst zurück:

def __iter__(self):
    return self

__next__ liefert jeweils das nächste Element und muss StopIteration auslösen, sobald keine weiteren Werte vorhanden sind.

Bei einem normalen Container wollen wir diesen Zustand nicht im Container selbst speichern.

Unsere Lösung:

def __iter__(self):
    return iter(self._gegenstaende)

erzeugt stattdessen bei jedem Aufruf einen neuen Iterator:

erster_iterator = iter(inventar)
zweiter_iterator = iter(inventar)

print(erster_iterator is zweiter_iterator)

Ausgabe:

False

Dadurch können mehrere Schleifen unabhängig voneinander über dasselbe Inventar laufen.

Die vorhandene Listenimplementierung erledigt die komplizierteren Details bereits für uns. Delegieren ist hier besser, als einen eigenen Iterator nachzubauen.

__contains__: den in-Operator unterstützen

Mit __contains__ können wir Membership Tests ausdrücklich implementieren:

class Inventar:
    # ...

    def __contains__(self, gegenstand):
        return gegenstand in self._gegenstaende

Nun:

fackel = Gegenstand("Fackel")
inventar = Inventar([fackel])

print(fackel in inventar)

Ausgabe:

True

Da Gegenstand eine Dataclass mit Equality nach Field-Werten ist, funktioniert auch:

print(Gegenstand("Fackel") in inventar)

mit dem Ergebnis:

True

in besitzt eigene Fallbacks

Für unser Inventar wäre __contains__ technisch nicht zwingend nötig.

Wenn die Methode fehlt, versucht Python für:

wert in objekt

nacheinander:

  1. __contains__
  2. Iteration über __iter__
  3. den alten Sequence-Fallback über __getitem__

Da unser Inventar bereits __iter__ besitzt, könnte Python die Gegenstände auch ohne eigene __contains__-Methode durchsuchen.

Eine eigene Implementierung kann trotzdem sinnvoll sein, wenn sie die Bedeutung klarer macht oder effizienter suchen kann.

Bei einem Mapping sollte Membership normalerweise Schlüssel bedeuten. Bei einer Sequence werden üblicherweise die enthaltenen Werte geprüft.

Fachliche Suche gehört nicht zwingend in __contains__

Man könnte __contains__ so programmieren, dass zusätzlich Strings als Namen interpretiert werden:

def __contains__(self, wert):
    if isinstance(wert, str):
        return any(
            gegenstand.name.lower() == wert.lower()
            for gegenstand in self._gegenstaende
        )

    return wert in self._gegenstaende

Dann würde:

"Fackel" in inventar

funktionieren.

Die Bedeutung von in hängt damit aber plötzlich vom Typ des linken Operanden ab.

Expliziter ist eine benannte Methode:

def finde(self, name):
    gesuchter_name = name.strip().lower()

    for gegenstand in self._gegenstaende:
        if gegenstand.name.lower() == gesuchter_name:
            return gegenstand

    return None

Verwendung:

gegenstand = inventar.finde("fackel")

if gegenstand is not None:
    print(f"Gefunden: {gegenstand}")

Dunder Methods sollten möglichst das Verhalten bieten, das Leser von der entsprechenden Python-Syntax erwarten.

Fachspezifische Operationen verdienen häufig einen normalen Methodennamen.

__getitem__: Indexzugriff und Slicing

Mit __getitem__ bekommt das Inventar eckige Klammern:

class Inventar:
    # ...

    def __getitem__(self, index):
        return self._gegenstaende[index]

Jetzt:

inventar = Inventar(
    [
        Gegenstand("Fackel"),
        Gegenstand("Brot"),
        Gegenstand("Schlüssel"),
    ]
)

print(inventar[0])
print(inventar[-1])

Ausgabe:

Fackel
Schlüssel

Bei:

inventar[0]

bekommt __getitem__ den Integer 0.

Ein Slice:

inventar[0:2]

wird dagegen als slice-Objekt übergeben.

Da wir direkt an die interne Liste delegieren, funktioniert Slicing bereits:

print(inventar[0:2])

Das Ergebnis ist allerdings eine normale Liste:

[Gegenstand(name='Fackel', beschreibung=''), Gegenstand(name='Brot', beschreibung='')]

Wenn ein Slice wieder ein Inventar liefern soll:

def __getitem__(self, index):
    ergebnis = self._gegenstaende[index]

    if isinstance(index, slice):
        return Inventar(ergebnis)

    return ergebnis

Nun ergibt:

reisegepaeck = inventar[:2]

print(type(reisegepaeck))
print(reisegepaeck)

wieder ein Inventar.

Iteration kann auf __getitem__ zurückfallen

Python unterstützt noch ein älteres Sequence Protocol.

Wenn eine Klasse kein __iter__ besitzt, kann Iteration über __getitem__ funktionieren. Python beginnt mit Index 0 und erhöht ihn so lange, bis IndexError ausgelöst wird.

Diese Klasse ist deshalb iterierbar:

class Zahlen:
    def __getitem__(self, index):
        werte = [10, 20, 30]
        return werte[index]
for zahl in Zahlen():
    print(zahl)

Ausgabe:

10
20
30

Die interne Liste sorgt dafür, dass nach Index 2 ein IndexError entsteht.

Eine moderne Containerklasse sollte trotzdem normalerweise ein echtes __iter__ implementieren. Die Schnittstelle ist damit eindeutig und kann effizienter sein.

Eine weitere Feinheit:

__iter__ = None

markiert einen Typ ausdrücklich als nicht iterierbar. Python fällt dann nicht auf __getitem__ zurück.

reversed(...) und __reversed__

Für:

reversed(inventar)

gibt es die Special Method:

__reversed__

Sie ist aber ebenfalls optional.

Fehlt sie, kann Python bei einem passenden Sequence-artigen Objekt auf __len__ und __getitem__ zurückgreifen.

Unser Inventar besitzt beide Methoden. Deshalb funktioniert bereits:

for gegenstand in reversed(inventar):
    print(gegenstand)

Ausgabe:

Schlüssel
Brot
Fackel

Eine eigene Implementierung ist nur nötig, wenn die Klasse eine sinnvollere oder effizientere Möglichkeit kennt:

def __reversed__(self):
    return reversed(self._gegenstaende)

__repr__ und __str__

Beide Methoden erzeugen Text, verfolgen aber unterschiedliche Ziele.

__repr__: nützlich beim Entwickeln

__repr__ sollte möglichst eindeutig und informationsreich sein:

def __repr__(self):
    return f"Inventar({self._gegenstaende!r})"

Damit ergibt:

print(repr(inventar))

beispielsweise:

Inventar([Gegenstand(name='Fackel', beschreibung=''), Gegenstand(name='Brot', beschreibung=''), Gegenstand(name='Schlüssel', beschreibung='')])

Wenn möglich, darf eine repr wie ein Python-Ausdruck aussehen, mit dem sich ein gleichwertiges Objekt rekonstruieren ließe:

Inventar([...])

Das ist aber kein Zwang.

Wichtiger ist, dass die Darstellung beim Debugging erkennen lässt, um welchen Typ und welchen State es sich handelt.

__str__: für die normale Anzeige

__str__ darf kompakter sein:

def __str__(self):
    if not self:
        return "(leer)"

    return ", ".join(
        str(gegenstand)
        for gegenstand in self._gegenstaende
    )

Nun:

print(inventar)

Ausgabe:

Fackel, Brot, Schlüssel

Ein leeres Inventar erscheint als:

(leer)

Was passiert ohne __str__?

Definiert eine Klasse kein eigenes __str__, greift die Standardimplementierung auf ihre Repräsentation zurück.

Deshalb ist ein brauchbares __repr__ auch dann wertvoll, wenn Du keine menschenlesbare __str__-Variante benötigst.

Beide Methoden müssen einen String zurückgeben.

Das hier ist falsch:

def __str__(self):
    print("Inventar")

Die Methode gibt implizit None zurück. Ein Aufruf über str() oder print() endet deshalb mit einem TypeError.

__format__: eigene f-String-Formate

Die Funktion:

format(objekt, format_spec)

und damit auch f-Strings verwenden __format__.

Ohne eigene Implementierung funktioniert eine leere Formatangabe über das Standardverhalten letztlich wie str(objekt):

f"{inventar}"

Wir können unserem Inventar zusätzliche Formate geben:

def __format__(self, format_spec):
    if format_spec == "":
        return str(self)

    if format_spec == "anzahl":
        return str(len(self))

    if format_spec == "liste":
        return "\n".join(
            f"- {gegenstand}"
            for gegenstand in self._gegenstaende
        )

    raise ValueError(
        f"Unbekanntes Inventarformat: {format_spec!r}"
    )

Verwendung:

print(f"Inventar: {inventar}")
print(f"Gegenstände: {inventar:anzahl}")
print(f"{inventar:liste}")

Ausgabe:

Inventar: Fackel, Brot, Schlüssel
Gegenstände: 3
- Fackel
- Brot
- Schlüssel

Welche Bedeutung eine Format Specification besitzt, bestimmt grundsätzlich der jeweilige Typ.

Das ist derselbe Mechanismus, durch den ein float:

f"{3.14159:.2f}"

anders interpretiert als ein datetime:

f"{zeitpunkt:%d.%m.%Y}"

Eine eigene __format__-Methode sollte unbekannte Specs nicht stillschweigend ignorieren. Ein ValueError macht Tippfehler deutlich.

Eigene Formate sind nützlich, wenn ein Typ mehrere natürliche Textdarstellungen besitzt. Bei exotischen oder schwer entdeckbaren Formaten ist eine normale Methode manchmal klarer:

inventar.als_liste()

__add__: Inventare mit + verbinden

Operator Overloading ist sinnvoll, wenn ein Operator eine natürliche Bedeutung für den Typ besitzt.

Bei Listen bedeutet:

[1, 2] + [3, 4]

dass eine neue Liste mit dem Inhalt beider Seiten entsteht.

Für zwei Inventare können wir dieselbe Bedeutung wählen:

def __add__(self, other):
    if not isinstance(other, Inventar):
        return NotImplemented

    return Inventar(
        [
            *self._gegenstaende,
            *other._gegenstaende,
        ]
    )

Verwendung:

rucksack = Inventar(
    [
        Gegenstand("Fackel"),
        Gegenstand("Brot"),
    ]
)

truhe = Inventar(
    [
        Gegenstand("Schlüssel"),
        Gegenstand("Goldmünze"),
    ]
)

gesamt = rucksack + truhe

print(gesamt)

Ausgabe:

Fackel, Brot, Schlüssel, Goldmünze

Die ursprünglichen Inventare bleiben unverändert.

Warum NotImplemented wichtig ist

Kann unsere Methode mit einem Operandentyp nichts anfangen, sollte sie bei binären Operatoren normalerweise:

return NotImplemented

verwenden:

def __add__(self, other):
    if not isinstance(other, Inventar):
        return NotImplemented

    ...

NotImplemented bedeutet hier sinngemäß:

Diese Implementierung unterstützt diese Kombination von Operanden nicht. Wende die übrigen Regeln des Operators an.

Bei:

x + y

kann Python dadurch unter anderem eine reflektierte Implementierung auf der rechten Seite berücksichtigen:

y.__radd__(x)

Wenn schließlich keine Seite die Operation unterstützt, erzeugt Python selbst den passenden TypeError.

Das ist etwas völlig anderes als:

raise NotImplementedError

NotImplemented ist ein spezieller Rückgabewert für bestimmte binäre Operationen und Vergleiche.

NotImplementedError ist dagegen eine Exception. Sie wird beispielsweise manchmal verwendet, wenn eine Basisklasse eine Methode absichtlich noch nicht implementiert.

__add__, __radd__ und __iadd__

Für die Addition gibt es drei verwandte Methoden:

Methode Rolle
__add__ normale Addition aus Sicht des linken Operanden
__radd__ reflektierte Addition
__iadd__ In-place-Variante für +=

Die Realität ist etwas genauer als:

erst immer __add__, danach immer __radd__

Wenn die rechte Seite eine echte Unterklasse des linken Operandentyps ist und eine passende reflektierte Operation besitzt, kann deren Implementierung Vorrang erhalten.

Für normalen Anwendungscode reicht aber meistens:

Nicht unterstützte Operandenkombinationen mit NotImplemented ablehnen und Python die restliche Dispatch-Logik erledigen lassen.

+= muss nicht dasselbe tun wie +

Wenn eine Klasse kein __iadd__ besitzt, kann:

inventar += truhe

über die normale binäre Addition abgewickelt werden und sinngemäß zu:

inventar = inventar + truhe

führen.

Dann verweist inventar anschließend auf ein neues Objekt.

Eine eigene In-place-Implementierung könnte dagegen das bestehende Inventar verändern:

def __iadd__(self, other):
    if not isinstance(other, Inventar):
        return NotImplemented

    self._gegenstaende.extend(other._gegenstaende)

    return self

Ob eine In-place-Operation tatsächlich mutiert, hängt vom Typ ab. Immutable Typen wie int erzeugen auch bei += zwangsläufig einen neuen Wert.

Operatoren nur mit nachvollziehbarer Bedeutung überladen

Python erlaubt uns theoretisch sehr kreative Dinge:

spieler + fackel
raum * 3
monster / schluessel

Die wichtigere Frage ist, ob jemand beim Lesen eine vernünftige Vorstellung davon hat, was diese Ausdrücke tun.

Plausibel sind etwa:

vektor_1 + vektor_2
inventar_1 + inventar_2
zeitpunkt_1 < zeitpunkt_2

Weniger offensichtlich:

spieler + schluessel
raum / monster

Dafür sind Methoden wie:

spieler.nimm(schluessel)
raum.platziere(monster)

meist verständlicher.

Operator Overloading sollte vertraute Bedeutung nutzen und keine eigene Rätselsprache schaffen.

Equality mit __eq__

Mit __eq__ definieren wir, was == für unser Inventar bedeutet:

def __eq__(self, other):
    if not isinstance(other, Inventar):
        return NotImplemented

    return self._gegenstaende == other._gegenstaende

Nun:

inventar_1 = Inventar(
    [
        Gegenstand("Fackel"),
        Gegenstand("Brot"),
    ]
)

inventar_2 = Inventar(
    [
        Gegenstand("Fackel"),
        Gegenstand("Brot"),
    ]
)

print(inventar_1 == inventar_2)

Ausgabe:

True

Die beiden Objekte sind unterschiedlich, ihr Inhalt ist nach unserer Definition aber gleichwertig.

Bei einem fremden Typ geben wir:

NotImplemented

zurück.

Das ist meist besser als:

return False

weil Python damit die Vergleichsregeln der anderen Seite berücksichtigen kann.

Die Methode sollte auch nicht einfach voraussetzen, dass other die erwarteten Attribute besitzt:

# Ungünstig:
def __eq__(self, other):
    return self._gegenstaende == other._gegenstaende

Ein Vergleich mit einem völlig anderen Objekttyp könnte damit plötzlich einen AttributeError erzeugen.

Rich Comparisons besitzen keine __req__-Methoden

Bei arithmetischen Operatoren kennen wir Paare wie:

__add__
__radd__

Für Vergleiche existieren keine entsprechenden Methoden wie:

__req__
__rlt__

Stattdessen sind die vorhandenen Vergleichsmethoden gegenseitig reflektiert.

Für:

x < y

gehören beispielsweise zusammen:

x.__lt__(y)
y.__gt__(x)

Entsprechend:

__le__  ↔  __ge__
__eq__  ↔  __eq__
__ne__  ↔  __ne__

Auch hier spielt die Typbeziehung der beiden Operanden eine Rolle. Ist der Typ der rechten Seite eine Unterklasse des linken Typs, bekommt seine reflektierte Vergleichsmethode Vorrang.

Virtuelle Subklassen über eine ABC-Registrierung zählen für diese Priorität nicht.

Für == und != gibt es außerdem einen letzten Fallback: Wenn keine passende Implementierung einen anderen Wert als NotImplemented liefert, vergleicht Python letztlich die Identität der Objekte.

Identität und Equality unterscheiden

Diese beiden Operatoren beantworten unterschiedliche Fragen:

x == y
x is y

== verwendet die Equality-Semantik der beteiligten Typen.

is prüft ausschließlich:

Sind beide Referenzen exakt dasselbe Objekt?

inventar_1 = Inventar([Gegenstand("Fackel")])
inventar_2 = Inventar([Gegenstand("Fackel")])

print(inventar_1 == inventar_2)
print(inventar_1 is inventar_2)

Ausgabe:

True
False

Die Inventare sind nach unserer Definition gleichwertig, aber zwei verschiedene Objekte.

Für Singletons wie None verwendest Du weiterhin eine Identitätsprüfung:

if gegenstand is None:
    ...

Sortierung und Rich Comparisons

Python kennt sechs Rich Comparison Methods:

Operator Methode
< __lt__
<= __le__
== __eq__
!= __ne__
> __gt__
>= __ge__

Für Gegenstände könnte eine Sortierung nach Wert und anschließend Name sinnvoll sein:

class Gegenstand:
    def __init__(self, name, wert=0):
        self.name = name
        self.wert = wert

    def __eq__(self, other):
        if not isinstance(other, Gegenstand):
            return NotImplemented

        return (self.wert, self.name) == (
            other.wert,
            other.name,
        )

    def __lt__(self, other):
        if not isinstance(other, Gegenstand):
            return NotImplemented

        return (self.wert, self.name) < (
            other.wert,
            other.name,
        )

Mit nur __eq__ und __lt__ funktioniert bereits vieles, etwa:

sorted(gegenstaende)

Die übrigen Vergleichsoperatoren entstehen dadurch aber nicht automatisch.

Wenn eine Klasse wirklich alle üblichen Ordnungsvergleiche anbieten soll, gibt es functools.total_ordering:

from functools import total_ordering


@total_ordering
class Gegenstand:
    def __init__(self, name, wert=0):
        self.name = name
        self.wert = wert

    def __eq__(self, other):
        if not isinstance(other, Gegenstand):
            return NotImplemented

        return (self.wert, self.name) == (
            other.wert,
            other.name,
        )

    def __lt__(self, other):
        if not isinstance(other, Gegenstand):
            return NotImplemented

        return (self.wert, self.name) < (
            other.wert,
            other.name,
        )

@total_ordering erzeugt aus __eq__ und einer Ordering-Methode die übrigen Vergleichsmethoden.

Das spart Boilerplate, bringt aber zusätzlichen Aufruf-Overhead und komplexere Stack Traces mit sich.

Für Dataclasses ist häufig:

from dataclasses import dataclass


@dataclass(order=True)
class Gegenstand:
    wert: int
    name: str

noch einfacher.

Die Field-Reihenfolge bestimmt dann auch die Sortierreihenfolge.

Equality und Hashing gehören zusammen

Objekte, die als:

dictionary[objekt] = wert

oder:

menge.add(objekt)

verwendet werden sollen, brauchen einen stabilen Hashwert.

Dafür gibt es:

__hash__

Die zentrale Regel lautet:

Wenn x == y gilt, muss auch hash(x) == hash(y) gelten.

Überschreibt eine Klasse __eq__, ohne ein passendes __hash__ bereitzustellen, setzt Python __hash__ normalerweise auf None.

Unsere mutable Inventarklasse ist deshalb mit inhaltsbasierter Equality richtigerweise nicht hashable:

inventar = Inventar()

hash(inventar)

führt zu:

TypeError: unhashable type: 'Inventar'

Das ist sinnvoll.

Ein Inventar kann Gegenstände aufnehmen und verlieren. Würde sein Hash vom Inhalt abhängen und sich verändern, während das Inventar bereits in einem Set oder als Dictionary-Key steckt, könnte die Hash-Tabelle es anschließend nicht mehr zuverlässig finden.

Mutable Objekte mit inhaltsbasierter Equality sollten deshalb normalerweise nicht hashable sein.

Ein unveränderliches Value Object ist ein anderer Fall:

from dataclasses import dataclass


@dataclass(frozen=True)
class Koordinate:
    x: int
    y: int

Bei geeigneten Fields erzeugt die frozen Dataclass einen passenden Hash.

Die vollständige Inventar-Klasse

Aus den bisherigen Schritten entsteht ein Container, der sich ziemlich natürlich in Python verwenden lässt:

class Inventar:
    def __init__(self, gegenstaende=None):
        self._gegenstaende = (
            list(gegenstaende)
            if gegenstaende is not None
            else []
        )

    def hinzufuegen(self, gegenstand):
        self._gegenstaende.append(gegenstand)

    def entfernen(self, gegenstand):
        self._gegenstaende.remove(gegenstand)

    def finde(self, name):
        gesuchter_name = name.strip().lower()

        for gegenstand in self._gegenstaende:
            if gegenstand.name.lower() == gesuchter_name:
                return gegenstand

        return None

    def __len__(self):
        return len(self._gegenstaende)

    def __iter__(self):
        return iter(self._gegenstaende)

    def __contains__(self, gegenstand):
        return gegenstand in self._gegenstaende

    def __getitem__(self, index):
        ergebnis = self._gegenstaende[index]

        if isinstance(index, slice):
            return Inventar(ergebnis)

        return ergebnis

    def __add__(self, other):
        if not isinstance(other, Inventar):
            return NotImplemented

        return Inventar(
            [
                *self._gegenstaende,
                *other._gegenstaende,
            ]
        )

    def __eq__(self, other):
        if not isinstance(other, Inventar):
            return NotImplemented

        return self._gegenstaende == other._gegenstaende

    def __repr__(self):
        return f"Inventar({self._gegenstaende!r})"

    def __str__(self):
        if not self:
            return "(leer)"

        return ", ".join(
            str(gegenstand)
            for gegenstand in self._gegenstaende
        )

    def __format__(self, format_spec):
        if format_spec == "":
            return str(self)

        if format_spec == "anzahl":
            return str(len(self))

        if format_spec == "liste":
            return "\n".join(
                f"- {gegenstand}"
                for gegenstand in self
            )

        raise ValueError(
            f"Unbekanntes Inventarformat: {format_spec!r}"
        )

Verwendung:

fackel = Gegenstand("Fackel")
brot = Gegenstand("Brot")
schluessel = Gegenstand("Schlüssel")

inventar = Inventar([fackel, brot])

print(len(inventar))
print(bool(inventar))
print(fackel in inventar)
print(inventar[0])
print(inventar[:1])

for gegenstand in inventar:
    print(gegenstand)

inventar_2 = Inventar([schluessel])
gesamt = inventar + inventar_2

print(gesamt)
print(repr(gesamt))
print(f"{gesamt:anzahl}")
print(f"{gesamt:liste}")

Ausgabe:

2
True
True
Fackel
Fackel
Fackel
Brot
Fackel, Brot, Schlüssel
Inventar([Gegenstand(name='Fackel', beschreibung=''), Gegenstand(name='Brot', beschreibung=''), Gegenstand(name='Schlüssel', beschreibung='')])
3
- Fackel
- Brot
- Schlüssel

Die interne Speicherung ist weiterhin nur:

self._gegenstaende

mit einer Liste dahinter.

Von außen verhält sich die Klasse aber wie ein eigener Python-Container:

len(inventar)
gegenstand in inventar
inventar[0]
inventar[:2]

for gegenstand in inventar:
    ...

inventar_1 + inventar_2
print(inventar)

Genau darum geht es beim Datenmodell.

Das Inventar in den Dungeon einbauen

In Spieler ersetzen wir:

self.inventar = []

durch:

self.inventar = Inventar()

Das Aufnehmen eines Gegenstands benutzt nun die öffentliche Schnittstelle:

def nimm(self, gegenstand):
    self.inventar.hinzufuegen(gegenstand)

Die Inventarausgabe kann ebenfalls das Protokoll verwenden:

def zeige_inventar(self):
    if not self.inventar:
        print("Dein Inventar ist leer.")
        return

    print(f"{self.inventar:liste}")

Und die Statuszeile braucht weiterhin nur:

len(self.inventar)
def __str__(self):
    return (
        f"{self.name} | "
        f"HP: {self.hp}/{self.max_hp} | "
        f"Gold: {self.gold} | "
        f"Inventar: {len(self.inventar)}"
    )

Spieler muss nicht mehr wissen, ob Inventar seine Gegenstände in einer Liste, einem Tuple oder einer völlig anderen Datenstruktur speichert.

Das ist der eigentliche Vorteil der Abstraktion.

Abstract Base Classes für Collections

Das Standardmodul collections.abc enthält Abstract Base Classes für typische Container-Schnittstellen:

from collections.abc import (
    Collection,
    Container,
    Iterable,
    Iterator,
    Mapping,
    MutableSequence,
    Sequence,
)

Einige davon definieren sehr kleine Protokolle.

Iterable verlangt:

__iter__

Container:

__contains__

Collection kombiniert:

__contains__
__iter__
__len__

Eine Sequence verlangt als abstrakte Kernmethoden insbesondere:

__getitem__
__len__

und liefert darauf aufbauend Mixins für:

__contains__
__iter__
__reversed__
index()
count()

Wir könnten unser Inventar deshalb ausdrücklich als Sequence modellieren:

from collections.abc import Sequence


class Inventar(Sequence):
    def __init__(self, gegenstaende=None):
        self._gegenstaende = (
            list(gegenstaende)
            if gegenstaende is not None
            else []
        )

    def __len__(self):
        return len(self._gegenstaende)

    def __getitem__(self, index):
        return self._gegenstaende[index]

Damit erhalten wir die übrigen Sequence-Operationen über die Basisklasse.

Das ist praktisch, wenn die Klasse tatsächlich die Semantik einer Sequence besitzt.

Die Mixins sind nicht automatisch optimal

Die ABCs können Methoden aus wenigen Kernoperationen ableiten. Das heißt aber nicht, dass diese Implementierungen für jede interne Datenstruktur effizient sind.

Bei Sequence verwenden beispielsweise __iter__, __reversed__ und index() wiederholt __getitem__.

Ist:

__getitem__

eine O(1)-Operation wie bei einer Liste, ist das meist unproblematisch.

Würde jeder Indexzugriff selbst O(n) benötigen, könnte ein darauf aufbauender Mixin plötzlich O(n²) kosten.

Eine passende ABC nimmt Dir also viel korrekte Standardsemantik ab. Sie ersetzt nicht die Überlegung, ob die resultierenden Algorithmen zur eigenen Datenstruktur passen.

isinstance(..., Iterable) kennt nicht jeden iterierbaren Typ

Noch eine Feinheit des alten __getitem__-Iterationsfallbacks:

Ein Objekt kann über:

__getitem__

iterierbar sein, ohne von:

isinstance(objekt, Iterable)

als Iterable erkannt zu werden.

collections.abc.Iterable erkennt Klassen mit __iter__ sowie entsprechend registrierte Typen.

Der zuverlässigste praktische Test lautet deshalb:

iter(objekt)

Wenn das funktioniert, kann Python tatsächlich einen Iterator erzeugen.

Weitere wichtige Special Methods

Unser Inventar deckt nur einen Teil des Datenmodells ab.

__call__: ein Objekt wie eine Funktion benutzen

Mit __call__ wird eine Instanz aufrufbar:

class Schaden:
    def __init__(self, faktor):
        self.faktor = faktor

    def __call__(self, basiswert):
        return basiswert * self.faktor
kritischer_treffer = Schaden(2)

print(kritischer_treffer(25))

Ausgabe:

50

kritischer_treffer ist weiterhin ein Objekt mit State, lässt sich aber mit Funktionssyntax verwenden.

__enter__ und __exit__: Context Manager

Ein synchroner Context Manager implementiert:

__enter__
__exit__

und kann dadurch in:

with ressource:
    ...

verwendet werden.

Der typische Zweck ist zuverlässiges Setup und Cleanup.

Dateien benutzen genau dieses Protokoll:

with open("daten.txt") as datei:
    inhalt = datei.read()

Auch bei einer Exception verlässt Python den Context über die dazugehörigen Regeln.

__new__ und __init__: Erzeugen und Initialisieren

__init__ ist wahrscheinlich die bekannteste Dunder Method:

class Spieler:
    def __init__(self, name):
        self.name = name

Streng genommen erzeugt __init__ das Objekt aber nicht.

Dafür ist:

__new__

zuständig.

Der Ablauf ist vereinfacht:

Klasse aufrufen
    ↓
__new__ erzeugt Instanz
    ↓
__init__ initialisiert Instanz

Bei normalen eigenen Klassen musst Du __new__ fast nie überschreiben.

Relevant wird es unter anderem bei immutable Typen oder bestimmten Metaprogramming-Szenarien.

__init__ darf keinen anderen Wert als None zurückgeben.

Attributzugriff beeinflussen

Mit:

__getattr__
__getattribute__
__setattr__
__delattr__

lässt sich beeinflussen, wie Attribute gelesen, geschrieben oder gelöscht werden.

Diese Methoden greifen tief in grundlegendes Objektverhalten ein.

Besonders __getattribute__ wird bei praktisch jedem normalen Attributzugriff beteiligt. Eine fehlerhafte Implementierung kann deshalb sehr schnell in endloser Rekursion enden.

Sie sollten nur eingesetzt werden, wenn der Anwendungsfall das wirklich erfordert.

Pattern Matching mit __match_args__

Positionale Class Patterns verwenden:

__match_args__

Beispielsweise:

class Punkt:
    __match_args__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y

Dann ist möglich:

match punkt:
    case Punkt(0, y):
        print(f"Y-Achse bei {y}")

Dataclasses erzeugen __match_args__ normalerweise automatisch für geeignete Fields.

Mehr dazu findest Du im Artikel match/case: Strukturelles Pattern Matching.

Asynchrone Protokolle

Für asynchronen Python-Code gibt es weitere Special Methods:

__await__
__aiter__
__anext__
__aenter__
__aexit__

Sie stecken hinter:

await objekt
async for element in objekt:
    ...

und:

async with objekt:
    ...

Die Idee bleibt dieselbe: Sprachsyntax greift über ein definiertes Protokoll auf das Objekt zu.

Nicht jedes Protokoll selbst nachbauen

Special Methods sind mächtig, besitzen aber teilweise subtile Regeln.

Bevor Du einen Container oder Operator komplett selbst implementierst, lohnt sich die Frage:

  • Kann eine eingebaute Collection intern die Arbeit übernehmen?
  • Kann ich eine Operation einfach an diese Collection delegieren?
  • Gibt es eine passende ABC in collections.abc?
  • Kann @dataclass die benötigten Methoden generieren?
  • Wäre eine normale, benannte Methode verständlicher?

Unsere Inventarklasse macht deshalb häufig nur:

return len(self._gegenstaende)
return iter(self._gegenstaende)
return gegenstand in self._gegenstaende
return self._gegenstaende[index]

Die interessanten fachlichen Regeln gehören uns. Die komplizierte Semantik einer Python-Liste müssen wir nicht neu erfinden.

__del__ ist kein verlässlicher Resource Manager

__del__ wird gelegentlich Destructor genannt. Genauer handelt es sich um einen Finalizer, der aufgerufen werden kann, wenn eine Instanz zerstört wird.

Darauf sollte man sich für wichtige externe Ressourcen aber nicht verlassen.

Der Zeitpunkt ist problematisch. __del__ kann beispielsweise während des Interpreter-Shutdowns laufen, wenn andere globale Objekte oder Module bereits nicht mehr in ihrem normalen Zustand sind.

Objekte können sich in __del__ sogar selbst wieder referenzieren und damit resurrecten. Das macht die Lebenszyklusregeln noch komplexer.

Außerdem ist nicht garantiert, dass __del__ für alle beim Interpreter-Ende noch vorhandenen Objekte überhaupt aufgerufen wird.

Für eine Datei, Netzwerkverbindung oder ähnliche Ressource ist daher:

with ...

die bessere Lösung.

Alternativ kommen je nach Fall infrage:

try:
    ...
finally:
    ...

eine explizite close()-Methode oder Mechanismen wie weakref.finalize.

__del__ ist kein Ersatz für einen Context Manager.

Keine eigenen Dunder-Namen erfinden

Namen mit jeweils zwei führenden und zwei abschließenden Unterstrichen gehören Pythons definierten Special Methods und möglichen zukünftigen Erweiterungen.

Erfinde deshalb nicht:

def __speichere_dungeon__(self):
    ...

Python verbindet damit keine besondere Semantik, und eine zukünftige Python-Version könnte denselben Namen theoretisch selbst definieren.

Eigene Methoden bekommen normale Namen:

def speichere_dungeon(self):
    ...

Ein Dunder-Name ist keine Schreibweise für:

Diese Methode ist besonders wichtig.

Er bedeutet:

Diese Methode gehört zu einem von Python definierten Protokoll.

Häufige Stolperfallen

  • Die Zuordnung zu wörtlich verstehen: len(x) ist konzeptionell mit __len__ verbunden, aber kein gewöhnlicher Aufruf von x.__len__().

  • Special Methods nur an einer Instanz setzen: Ein objekt.__len__ = ... reicht für len(objekt) nicht.

  • Dunder Methods direkt aufrufen: Verwende normalerweise len(x), repr(x), x + y oder element in x, damit Python die vollständigen Protokollregeln anwenden kann.

  • __len__ einen negativen Wert zurückgeben lassen: Die Länge muss eine nicht negative Ganzzahl sein.

  • __bool__ unnötig implementieren: Für Collections ergibt sich die natürliche Truthiness häufig bereits aus __len__.

  • Iterable und Iterator verwechseln: Ein Container erzeugt normalerweise über __iter__ einen Iterator. Der Iterator selbst besitzt __next__.

  • In jedem __iter__ einfach self zurückgeben: Das ist nur richtig, wenn das Objekt tatsächlich selbst ein Iterator ist.

  • Den alten __getitem__-Iterationsfallback vergessen: Iteration kann auch ohne __iter__ funktionieren. Eine moderne Containerklasse sollte trotzdem normalerweise ausdrücklich __iter__ bereitstellen.

  • Bei __getitem__ nur Integer erwarten: Beim Slicing erhält die Methode ein slice-Objekt.

  • Beim Sequence-Fallback nie IndexError auslösen: Dann weiß Python nicht, wann die Folge endet.

  • NotImplemented mit NotImplementedError verwechseln: Das eine ist ein spezieller Rückgabewert für bestimmte Operationen, das andere eine Exception.

  • Bei fremden Vergleichstypen vorschnell False zurückgeben: Häufig ist NotImplemented richtig, damit Python die andere Seite berücksichtigen kann.

  • Bei Vergleichen nach __req__ oder __rlt__ suchen: Solche Methoden gibt es nicht. Rich Comparisons reflektieren über die jeweils andere vorhandene Vergleichsmethode.

  • __eq__ ohne Gedanken an __hash__ definieren: Python macht Objekte mit eigener Equality ohne passenden Hash normalerweise unhashable.

  • Einen Hash aus mutablem State bilden: Ändert sich der Hash während ein Objekt in einem Set oder Dictionary steckt, kann die Datenstruktur es nicht mehr zuverlässig wiederfinden.

  • Operatoren mit überraschender Bedeutung versehen: Eine normale Methode ist besser als ein cleverer, aber rätselhafter Operator.

  • __str__, __repr__ oder __format__ keinen String zurückgeben lassen: Diese Protokolle erwarten einen String.

  • Eine ABC mit guter Performance verwechseln: Mixins wie die von Sequence sind semantisch praktisch, können bei einem teuren __getitem__ aber ineffizient sein.

  • Ressourcen mit __del__ verwalten: Für zuverlässiges Cleanup eignet sich ein Context Manager wesentlich besser.

  • Eigene Dunder-Namen erfinden: Verwende nur dokumentierte Special Methods.

Kompakte Übersicht

Gewünschtes Verhalten Special Method
Instanz erzeugen __new__
Instanz initialisieren __init__
Entwicklerdarstellung __repr__
menschenlesbare Darstellung __str__
f-String-Formatierung __format__
Länge __len__
Truthiness __bool__, sonst __len__
Iteration beginnen __iter__
nächstes Iterator-Element __next__
Membership Test __contains__
Indexzugriff und Slicing __getitem__
Wert über Index setzen __setitem__
Wert über Index löschen __delitem__
umgekehrte Iteration __reversed__
Addition __add__
reflektierte Addition __radd__
In-place-Addition __iadd__
Equality __eq__
Vergleich mit < __lt__
Hashwert __hash__
Objekt wie Funktion aufrufen __call__
Context Manager __enter__, __exit__
Attribute als Fallback lesen __getattr__
jeden Attributzugriff steuern __getattribute__
positionales Pattern Matching __match_args__
asynchron iterieren __aiter__, __anext__
asynchroner Context Manager __aenter__, __aexit__

Das Muster dahinter bleibt immer gleich.

Du möchtest, dass ein eigenes Objekt mit normaler Python-Syntax funktioniert:

len(inventar)

if inventar:
    ...

gegenstand in inventar
inventar[0]
inventar[:2]

for gegenstand in inventar:
    ...

inventar_1 + inventar_2
print(inventar)

Dann implementiert die Klasse die dazu passenden Protokolle und überlässt Python den Rest.

Die Kunst besteht weniger darin, möglichst viele Special Methods zu verwenden. Eine Klasse sollte genau die Operationen unterstützen, die zu ihrer fachlichen Bedeutung passen und sich für Leser so verhalten, wie die entsprechende Python-Syntax erwarten lässt.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!