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:
__bool__, falls vorhanden- andernfalls
__len__ - 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:
__contains__- Iteration über
__iter__ - 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
NotImplementedablehnen 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 == ygilt, muss auchhash(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
@dataclassdie 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 vonx.__len__(). -
Special Methods nur an einer Instanz setzen: Ein
objekt.__len__ = ...reicht fürlen(objekt)nicht. -
Dunder Methods direkt aufrufen: Verwende normalerweise
len(x),repr(x),x + yoderelement 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__einfachselfzurü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 einslice-Objekt. -
Beim Sequence-Fallback nie
IndexErrorauslösen: Dann weiß Python nicht, wann die Folge endet. -
NotImplementedmitNotImplementedErrorverwechseln: Das eine ist ein spezieller Rückgabewert für bestimmte Operationen, das andere eine Exception. -
Bei fremden Vergleichstypen vorschnell
Falsezurückgeben: Häufig istNotImplementedrichtig, 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
Sequencesind 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.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.