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. Der offizielle Begriff lautet Special Methods.
Hinter vielen vertrauten Operationen steckt eine solche Methode:
len(objekt)objekt[index]wert in objektobjekt_1 + objekt_2objekt_1 == objekt_2for element in objektprint(objekt)
Indem eine Klasse die passenden Special Methods implementiert, bindet sie sich in Pythons Datenmodell ein. Eigene Objekte können sich dadurch wie eingebaute Strings, Zahlen oder Collections verhalten.
Das Datenmodell als Sammlung von Protokollen
Das Python-Datenmodell beschreibt unter anderem:
- wie Objekte erzeugt werden,
- wie Attribute gefunden werden,
- wie Objekte als Text dargestellt werden,
- wie Vergleiche funktionieren,
- wie Collections iteriert werden,
- wie Operatoren ausgewertet werden,
- wie
with,awaitoder Funktionsaufrufe mit Objekten funktionieren.
Dabei gibt es nicht eine einzige große Schnittstelle, die jede Klasse vollständig implementieren müsste.
Stattdessen stellt Python viele kleine Protokolle bereit. Eine Klasse kann gezielt nur die Protokolle unterstützen, die zu ihrer fachlichen Bedeutung passen.
Eine Klasse mit __len__ besitzt beispielsweise eine Länge. Eine Klasse mit
__iter__ ist iterierbar. Eine Klasse mit __enter__ und __exit__ kann als
Context Manager in einem with-Block verwendet werden.
Syntax wird auf Special Methods abgebildet
Vereinfacht bildet Python viele Operationen auf Special Methods ab:
| Du schreibst | Beteiligte Special Method |
|---|---|
len(x) |
__len__ |
bool(x) |
__bool__, sonst __len__ |
x[index] |
__getitem__ |
x[index] = wert |
__setitem__ |
del x[index] |
__delitem__ |
element in x |
__contains__ |
for element in x |
__iter__ |
next(iterator) |
__next__ |
reversed(x) |
__reversed__ oder Sequence Protocol |
x + y |
__add__ und gegebenenfalls __radd__ |
x += y |
__iadd__ oder als Fallback __add__ |
x == y |
__eq__ |
x < y |
__lt__ |
hash(x) |
__hash__ |
repr(x) |
__repr__ |
str(x) und print(x) |
__str__ |
f"{x:...}" |
__format__ |
x() |
__call__ |
with x: |
__enter__ und __exit__ |
Diese Zuordnung ist konzeptionell zu verstehen. Python führt nicht in jedem Fall
einfach einen normalen Attributzugriff wie x.__len__() aus.
Special Methods werden auf der Klasse gesucht
Bei impliziten Operationen sucht Python Special Methods grundsätzlich auf dem Typ des Objekts.
Der Ausdruck
len(objekt)
entspricht deshalb eher:
type(objekt).__len__(objekt)
als:
objekt.__len__()
Der Unterschied wird sichtbar, wenn wir eine Special Method nur an einem einzelnen Objekt anlegen:
class Beutel:
pass
beutel = Beutel()
beutel.__len__ = lambda: 5
print(beutel.__len__())
Die direkte Ausgabe funktioniert:
5
len(...) findet die Methode jedoch nicht:
print(len(beutel))
Python meldet:
TypeError: object of type 'Beutel' has no len()
Damit len(beutel) funktioniert, muss __len__ auf der Klasse definiert sein:
class Beutel:
def __len__(self):
return 5
Nun liefert:
beutel = Beutel()
print(len(beutel))
die Ausgabe:
5
Diese besondere Suche ermöglicht dem Interpreter Optimierungen und verhindert Mehrdeutigkeiten bei Typobjekten.
Für den normalen Anwendungscode reicht die Regel:
Implementiere Special Methods in der Klasse und verwende anschließend die dazugehörige Python-Syntax oder Built-in Function.
Special Methods möglichst nicht direkt aufrufen
Auch wenn dieser Aufruf technisch möglich ist:
anzahl = inventar.__len__()
solltest Du normalerweise die öffentliche Operation verwenden:
anzahl = len(inventar)
Entsprechend gilt:
repr(objekt) # statt objekt.__repr__()
str(objekt) # statt objekt.__str__()
element in objekt # statt objekt.__contains__(element)
objekt_1 + objekt_2 # statt objekt_1.__add__(objekt_2)
Die normale Syntax ist nicht nur besser lesbar. Sie berücksichtigt auch Fallbacks, reflektierte Operatoren und andere Regeln des Datenmodells.
Special Methods sind in erster Linie für Python selbst gedacht. Dein Anwendungscode verwendet die daraus entstehende Schnittstelle.
Ein Inventar als eigener Container
Im Dungeon liegt das Inventar bisher als normale Liste im Spielerobjekt:
self.inventar = []
Das funktioniert, erlaubt aber beliebige direkte Änderungen:
spieler.inventar.append(gegenstand)
spieler.inventar.clear()
spieler.inventar = "keine Liste mehr"
Wir bauen stattdessen eine Klasse Inventar, die intern weiterhin eine Liste
verwendet, aber eine gezielte Schnittstelle bereitstellt.
Als Gegenstände verwenden wir die Dataclass aus dem vorherigen Artikel:
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Gegenstand:
name: str
beschreibung: str = ""
def __str__(self):
return self.name
Die erste Version des Inventars sieht so aus:
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:
Dieses Attribut ist ein internes Implementierungsdetail.
Python erzwingt diese Privatheit nicht. Benutzer der Klasse sollen aber möglichst die öffentliche Schnittstelle verwenden.
__len__: eine Länge bereitstellen
Mit __len__ unterstützen wir len(...):
class Inventar:
def __init__(self, gegenstaende=None):
self._gegenstaende = (
list(gegenstaende)
if gegenstaende is not None
else []
)
def __len__(self):
return len(self._gegenstaende)
Verwendung:
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 wäre ungültig:
def __len__(self):
return -1
Ein Aufruf von len(...) würde einen ValueError auslösen.
Truthiness fällt auf __len__ zurück
Für Truthiness gibt es die Special Method __bool__:
def __bool__(self):
return bool(self._gegenstaende)
Wir benötigen sie für unser Inventar allerdings nicht.
Definiert eine Klasse kein __bool__, verwendet Python als Fallback
__len__. Ein Objekt mit der Länge 0 gilt als falsy, ein Objekt mit einer
größeren Länge als truthy.
Durch unsere vorhandene __len__-Methode funktioniert deshalb bereits:
inventar = Inventar()
if inventar:
print("Du trägst etwas bei Dir.")
else:
print("Dein Inventar ist leer.")
Ausgabe:
Dein Inventar ist leer.
Nach dem Hinzufügen eines Gegenstands:
inventar.hinzufuegen(Gegenstand("Fackel"))
if inventar:
print("Du trägst etwas bei Dir.")
erscheint:
Du trägst etwas bei Dir.
Die Fallback-Reihenfolge lautet:
- Python verwendet
__bool__, falls vorhanden. - Andernfalls verwendet es
__len__. - Fehlen beide Methoden, gilt das Objekt grundsätzlich als truthy.
Implementiere __bool__ nur, wenn Truthiness etwas anderes bedeuten soll als
„Länge ist größer als null“.
__iter__: das Inventar durchlaufen
Damit ein Objekt in einer for-Schleife verwendet werden kann, 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 Funktion iter(...) erzeugt aus der internen Liste einen Iterator.
Unsere Inventar-Klasse ist ein Iterable, aber nicht selbst der Iterator.
Das ist für Container normalerweise die richtige Trennung.
Jeder Aufruf von iter(inventar) liefert 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.
Iterable und Iterator unterscheiden
Ein Iterable stellt __iter__ bereit und kann daraus einen Iterator erzeugen.
Ein Iterator benötigt zwei Methoden:
__iter__()
__next__()
Sein __iter__ gibt üblicherweise den Iterator selbst zurück:
def __iter__(self):
return self
__next__ liefert jeweils das nächste Element. Sind keine Elemente mehr
vorhanden, muss es StopIteration auslösen.
Für unsere Containerklasse müssen wir diesen Mechanismus nicht selbst implementieren. Die interne Liste besitzt bereits einen korrekt arbeitenden Iterator:
def __iter__(self):
return iter(self._gegenstaende)
Delegation an einen eingebauten Typ ist meist sicherer und einfacher als ein eigener Iterator.
__contains__: den in-Operator steuern
Mit __contains__ unterstützen wir Membership Tests:
class Inventar:
# ...
def __contains__(self, gegenstand):
return gegenstand in self._gegenstaende
Nun können wir prüfen:
fackel = Gegenstand("Fackel")
inventar = Inventar([fackel])
print(fackel in inventar)
Ausgabe:
True
Da Gegenstand eine Dataclass ist, vergleicht sie standardmäßig nach ihren
Field-Werten:
print(Gegenstand("Fackel") in inventar)
Auch diese Prüfung ergibt:
True
Der Fallback des in-Operators
__contains__ ist für unser Inventar nicht zwingend notwendig.
Fehlt die Methode, versucht Python nacheinander:
- über
__iter__zu iterieren, - als älteren Fallback über
__getitem__ab Index0zu iterieren.
Da unser Inventar __iter__ implementiert, würde in bereits funktionieren.
Ein eigenes __contains__ kann die Absicht trotzdem deutlicher machen oder eine
effizientere Suche ermöglichen.
Bei einer Mapping-Klasse sollte in üblicherweise die Schlüssel prüfen. Bei
einer Sequence prüft es normalerweise deren Werte.
Nach einem Namen suchen
Der Ausdruck
"Fackel" in inventar
ist bei einem Inventar mit Gegenstand-Objekten nicht automatisch sinnvoll. Der
String "Fackel" ist schließlich kein Gegenstand.
Wir könnten __contains__ so programmieren, dass es abhängig vom Typ
unterschiedliche Bedeutungen besitzt. Das wäre aber überraschend:
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
Klarer ist eine ausdrücklich 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 eine möglichst erwartbare Bedeutung haben. Fachlich spezielle Operationen verdienen häufig einen normalen Methodennamen.
__getitem__: Indexzugriff und Slicing
Mit __getitem__ unterstützen wir eckige Klammern:
class Inventar:
# ...
def __getitem__(self, index):
return self._gegenstaende[index]
Nun funktioniert der Zugriff über einen Index:
inventar = Inventar(
[
Gegenstand("Fackel"),
Gegenstand("Brot"),
Gegenstand("Schlüssel"),
]
)
print(inventar[0])
print(inventar[-1])
Ausgabe:
Fackel
Schlüssel
Python übergibt den Inhalt der eckigen Klammern an __getitem__.
Bei:
inventar[0]
erhält die Methode den Integer 0.
Bei einem Slice:
inventar[0:2]
erhält sie dagegen ein slice-Objekt.
Da wir den Zugriff direkt an die interne Liste weiterreichen, funktioniert Slicing bereits:
print(inventar[0:2])
Das Ergebnis wäre allerdings eine normale Liste:
[Gegenstand(name='Fackel', beschreibung=''), Gegenstand(name='Brot', beschreibung='')]
Möchten wir bei einem Slice wieder ein Inventar erhalten, unterscheiden wir
die beiden Fälle:
def __getitem__(self, index):
ergebnis = self._gegenstaende[index]
if isinstance(index, slice):
return Inventar(ergebnis)
return ergebnis
Nun liefert:
reisegepaeck = inventar[:2]
print(type(reisegepaeck))
print(reisegepaeck)
wieder ein Inventar-Objekt.
__getitem__ kann als Iterations-Fallback dienen
Eine ältere Form des Sequence Protocol erlaubt Iteration allein über
__getitem__.
Python beginnt dabei bei Index 0 und erhöht den Index, bis __getitem__
einen IndexError auslöst.
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
Trotzdem sollte eine moderne Containerklasse __iter__ ausdrücklich
implementieren. Das macht die Schnittstelle deutlich und kann effizienter sein.
Setzt eine Klasse __iter__ = None, gilt sie ausdrücklich als nicht iterierbar.
Python fällt dann nicht auf __getitem__ zurück.
reversed(...) und das Sequence Protocol
Für reversed(objekt) gibt es die Methode __reversed__.
Fehlt sie, kann Python bei sequenzartigen Objekten 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
Ein eigenes __reversed__ wäre nur notwendig, wenn wir die umgekehrte Iteration
anders oder effizienter implementieren könnten:
def __reversed__(self):
return reversed(self._gegenstaende)
__repr__ und __str__
Beide Methoden erzeugen Strings, erfüllen aber unterschiedliche Aufgaben.
__repr__: für Entwickler
__repr__ soll möglichst eindeutig und informationsreich sein:
def __repr__(self):
return f"Inventar({self._gegenstaende!r})"
Verwendung:
print(repr(inventar))
Ausgabe:
Inventar([Gegenstand(name='Fackel', beschreibung=''), Gegenstand(name='Brot', beschreibung=''), Gegenstand(name='Schlüssel', beschreibung='')])
Ideal ist eine Darstellung, die wie gültiger Python-Code aussieht und ein gleichwertiges Objekt erzeugen könnte. Das ist jedoch kein absolutes Muss.
Wichtiger ist, dass repr(...) beim Debugging erkennen lässt, um welchen Typ und
welchen State es sich handelt.
__str__: für Menschen
__str__ darf kompakter und benutzerfreundlicher sein:
def __str__(self):
if not self:
return "(leer)"
return ", ".join(
str(gegenstand)
for gegenstand in self._gegenstaende
)
Nun ergibt:
print(inventar)
die Ausgabe:
Fackel, Brot, Schlüssel
Ein leeres Inventar erscheint als:
(leer)
Der Fallback
Definiert eine Klasse __repr__, aber kein eigenes __str__, verwendet
str(objekt) ebenfalls die repr-Darstellung.
Darum lohnt sich ein sinnvolles __repr__ für fast jede eigene Datenklasse.
__str__ ergänzt Du, wenn zusätzlich eine Darstellung für Benutzer gebraucht
wird.
Beide Methoden müssen einen String zurückgeben. Eine direkte Ausgabe innerhalb der Methode reicht nicht:
# Falsch:
def __str__(self):
print("Inventar")
Dieser Code führt bei str(objekt) zu einem TypeError, weil die Methode
implizit None zurückgibt.
__format__: eigene f-String-Formate
f-Strings verwenden intern __format__:
f"{inventar}"
Ohne eigene Implementierung delegiert die leere Formatangabe letztlich an
__str__.
Wir können zusätzliche Formate anbieten:
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
Eigene Format Specs sind nützlich, wenn ein Typ mehrere natürliche Textdarstellungen besitzt.
Sie sollten sparsam und gut dokumentiert eingesetzt werden. Eine normale Methode
wie inventar.als_liste() ist manchmal leichter zu entdecken.
__add__: Inventare kombinieren
Operator Overloading ist sinnvoll, wenn ein Operator eine natürliche Bedeutung für den Typ besitzt.
Zwei Listen werden mit + verkettet:
[1, 2] + [3, 4]
Für zwei Inventare ist eine ähnliche Bedeutung nachvollziehbar: Es entsteht ein neues Inventar mit den Gegenständen beider Seiten.
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 beiden ursprünglichen Inventare bleiben unverändert.
Warum NotImplemented zurückgegeben wird
Kann __add__ mit dem rechten Operanden nichts anfangen, sollte die Methode
nicht voreilig selbst einen TypeError auslösen:
def __add__(self, other):
if not isinstance(other, Inventar):
return NotImplemented
NotImplemented signalisiert dem Interpreter:
Diese konkrete Operation für diese Operanden unterstütze ich nicht. Probiere die vorgesehenen Fallbacks.
Bei x + y kann Python dadurch unter anderem die reflektierte Methode
y.__radd__(x) versuchen.
Findet sich keine passende Implementierung, löst Python schließlich selbst einen
TypeError aus.
NotImplemented ist ein besonderer Rückgabewert und nicht mit der Exception
NotImplementedError zu verwechseln.
return NotImplemented
und
raise NotImplementedError
haben vollständig unterschiedliche Bedeutungen.
NotImplementedError wird typischerweise verwendet, wenn eine Methode in einer
Basisklasse bewusst noch keine Implementierung besitzt. Für nicht unterstützte
Operator-Operanden ist NotImplemented richtig.
__add__, __radd__ und __iadd__
Für binäre Operatoren gibt es drei verwandte Varianten:
| Methode | Operation |
|---|---|
__add__ |
x + y aus Sicht von x |
__radd__ |
Fallback aus Sicht von y |
__iadd__ |
x += y |
Definiert eine Klasse kein __iadd__, kann Python für += auf __add__
zurückfallen und den Namen anschließend an das neue Ergebnis binden.
Bei unserer bisherigen Klasse:
inventar += truhe
entspricht das sinngemäß:
inventar = inventar + truhe
Das ursprüngliche Objekt wird nicht verändert. inventar verweist danach auf
ein neues Objekt.
Eine eigene __iadd__-Methode könnte stattdessen das bestehende Inventar
verändern:
def __iadd__(self, other):
if not isinstance(other, Inventar):
return NotImplemented
self._gegenstaende.extend(other._gegenstaende)
return self
Ob + und += neue Werte erzeugen oder bestehende Objekte verändern, sollte zum
Modell passen und für Benutzer der Klasse nicht überraschend sein.
Für den Dungeon ist eine ausdrücklich benannte Methode oft klarer:
inventar.hinzufuegen(gegenstand)
Operatoren nur mit natürlicher Bedeutung überladen
Technisch könnten wir festlegen:
spieler + fackel
oder:
raum * 3
Die entscheidende Frage lautet aber nicht, ob Python das erlaubt, sondern ob die Bedeutung für Leser sofort verständlich ist.
Natürlich wirken beispielsweise:
vektor_1 + vektor_2
inventar_1 + inventar_2
zeitpunkt_1 < zeitpunkt_2
Weniger natürlich wären:
spieler + schluessel
raum / monster
In solchen Fällen ist eine benannte Methode meist deutlicher:
spieler.nimm(schluessel)
raum.platziere(monster)
Operator Overloading sollte ein bestehendes mentales Modell unterstützen und keine versteckte Rätselsprache erzeugen.
Equality mit __eq__
Mit __eq__ bestimmen wir, was == für unsere Objekte bedeutet:
def __eq__(self, other):
if not isinstance(other, Inventar):
return NotImplemented
return self._gegenstaende == other._gegenstaende
Nun werden die enthaltenen Gegenstände und ihre Reihenfolge verglichen:
inventar_1 = Inventar(
[
Gegenstand("Fackel"),
Gegenstand("Brot"),
]
)
inventar_2 = Inventar(
[
Gegenstand("Fackel"),
Gegenstand("Brot"),
]
)
print(inventar_1 == inventar_2)
Ausgabe:
True
Ein Vergleich mit einem unbekannten Typ liefert intern NotImplemented:
inventar_1 == ["Fackel", "Brot"]
Python kann dadurch die Vergleichslogik der anderen Seite beziehungsweise seinen vorgesehenen Fallback verwenden.
__eq__ sollte nicht einfach voraussetzen, dass other alle benötigten
Attribute besitzt:
# Ungünstig:
def __eq__(self, other):
return self._gegenstaende == other._gegenstaende
Beim Vergleich mit einem fremden Typ würde sonst möglicherweise ein
AttributeError entstehen.
Identität und Equality unterscheiden
Diese beiden Prüfungen beantworten unterschiedliche Fragen:
x == y
x is y
== fragt:
Gelten beide Objekte nach ihrer Equality-Definition als gleichwertig?
is fragt:
Handelt es sich um 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 besitzen denselben Inhalt, sind aber zwei verschiedene Objekte.
Für 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 ihrem Wert sinnvoll sein:
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.
Nun funktioniert:
gegenstaende = [
Gegenstand("Krone", 500),
Gegenstand("Brot", 2),
Gegenstand("Fackel", 10),
]
for gegenstand in sorted(gegenstaende):
print(gegenstand.name, gegenstand.wert)
total_ordering spart Boilerplate, erzeugt aber zusätzlichen Overhead und
komplexere Stack Traces. Bei performancekritischen Klassen können alle
Vergleichsmethoden ausdrücklich implementiert werden.
Für Dataclasses ist häufig diese Variante einfacher:
from dataclasses import dataclass
@dataclass(order=True)
class Gegenstand:
wert: int
name: str
Die Fields werden dann in ihrer definierten Reihenfolge verglichen.
Equality und Hashing gehören zusammen
Hashable Objekte können verwendet werden als:
- Dictionary-Schlüssel,
- Set-Elemente,
- Elemente eines
frozenset.
Dafür dient __hash__.
Die wichtigste Regel lautet:
Objekte, die mit
==gleich sind, müssen denselben Hashwert besitzen.
Definiert eine Klasse __eq__, aber kein passendes __hash__, macht Python ihre
Instanzen normalerweise unhashable:
inventar = Inventar()
hash(inventar)
Das führt zu:
TypeError: unhashable type: 'Inventar'
Für unser Inventar ist das richtig. Es ist mutable: Gegenstände können hinzugefügt oder entfernt werden.
Würde sich der Hashwert verändern, während das Objekt als Dictionary-Schlüssel oder in einem Set liegt, könnte Python es nicht mehr zuverlässig wiederfinden.
Mutable Klassen mit inhaltsbasierter Equality sollten deshalb normalerweise nicht hashable sein.
Für unveränderliche Value Objects eignet sich beispielsweise:
from dataclasses import dataclass
@dataclass(frozen=True)
class Koordinate:
x: int
y: int
Die frozen Dataclass erzeugt bei geeigneten Fields eine passende
__hash__-Methode.
Die vollständige Inventar-Klasse
Unsere bisherigen Methoden ergeben zusammen einen natürlich verwendbaren Container:
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 Liste ist weiterhin vorhanden. Benutzer der Klasse müssen ihre Implementierung aber nicht kennen.
Sie verwenden vertraute Python-Operationen:
len(inventar)
gegenstand in inventar
inventar[0]
inventar[:2]
for gegenstand in inventar
inventar_1 + inventar_2
print(inventar)
Das Inventar in den Dungeon einbauen
In der Klasse Spieler ersetzen wir die Liste:
self.inventar = []
durch:
self.inventar = Inventar()
Die Methode zum Aufnehmen eines Gegenstands verwendet nun die öffentliche Schnittstelle:
def nimm(self, gegenstand):
self.inventar.hinzufuegen(gegenstand)
Die Ausgabe wird deutlich kürzer:
def zeige_inventar(self):
if not self.inventar:
print("Dein Inventar ist leer.")
return
print(f"{self.inventar:liste}")
Auch die Statuszeile funktioniert unverändert mit len(...):
def __str__(self):
return (
f"{self.name} | "
f"HP: {self.hp}/{self.max_hp} | "
f"Gold: {self.gold} | "
f"Inventar: {len(self.inventar)}"
)
Der Spieler muss nicht wissen, dass das Inventar seine Gegenstände intern in einer Liste speichert.
Abstract Base Classes für Container
Das Standardmodul collections.abc stellt Abstract Base Classes für typische
Collection-Schnittstellen bereit:
from collections.abc import (
Collection,
Container,
Iterable,
Iterator,
Mapping,
MutableSequence,
Sequence,
)
Eine Collection setzt beispielsweise diese grundlegenden Operationen voraus:
__contains__
__iter__
__len__
Eine Sequence benötigt insbesondere:
__getitem__
__len__
und stellt darauf aufbauend weitere Methoden bereit.
Eine eigene Klasse kann direkt von einer solchen ABC erben:
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]
Sequence ergänzt auf Grundlage dieser Kernmethoden unter anderem
Implementierungen für:
__contains____iter____reversed__index(...)count(...)
Das kann nützlich sein, wenn eine eigene Klasse ausdrücklich die vollständige Schnittstelle eines bestimmten Collection Types anbieten soll.
Eine Klasse wird aber nicht allein dadurch eine gute Sequence, dass sie zufällig
__len__ und __getitem__ besitzt. Auch die Bedeutung und das Verhalten der
Operationen müssen zum Protokoll passen.
Weitere wichtige Special Methods
Das Datenmodell reicht weit über Container und Operatoren hinaus.
Objekte aufrufbar machen
Mit __call__ kann ein Objekt wie eine Funktion verwendet werden:
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
Context Manager bereitstellen
Mit __enter__ und __exit__ unterstützt ein Objekt das with-Statement:
with ressourcenzugriff:
...
Context Manager kümmern sich typischerweise um zuverlässiges Setup und Cleanup.
Attributzugriffe beeinflussen
Mit Methoden wie:
__getattr__
__getattribute__
__setattr__
kann eine Klasse Attributzugriffe verändern.
Diese Methoden greifen tief in fundamentales Verhalten ein und sollten nur mit einem klaren Anwendungsfall überschrieben werden.
Pattern Matching unterstützen
__match_args__ legt fest, welche Attribute bei positionalen Class Patterns
verwendet werden:
match punkt:
case Punkt(0, y):
...
Dataclasses erzeugen __match_args__ normalerweise automatisch für geeignete
Fields.
Asynchrone Protokolle
Asynchroner Python-Code verwendet weitere Special Methods:
__await__
__aiter__
__anext__
__aenter__
__aexit__
Sie bilden die Grundlage für await, async for und async with.
Nicht jedes Verhalten selbst implementieren
Special Methods sind mächtig, aber viele davon besitzen subtile Regeln.
Bevor Du ein eigenes Protokoll vollständig nachbaust, prüfe:
- Kann eine eingebaute Collection intern verwendet werden?
- Kann ich die Operation an die interne Collection delegieren?
- Gibt es eine passende Abstract Base Class?
- Erzeugt
@dataclassdie benötigten Methoden bereits? - Ist eine normal benannte Methode verständlicher?
Unsere Inventarklasse delegiert deshalb viel Verhalten an eine Liste:
return len(self._gegenstaende)
return iter(self._gegenstaende)
return gegenstand in self._gegenstaende
return self._gegenstaende[index]
So profitieren wir von der getesteten Semantik des eingebauten Typs.
__del__ ist kein verlässlicher Resource Manager
Die Methode __del__ wird manchmal als Destructor bezeichnet. Sie kann
ausgeführt werden, wenn ein Objekt zerstört wird.
Der genaue Zeitpunkt ist jedoch nicht zuverlässig planbar. Bei zyklischen Referenzen, während des Interpreter-Shutdowns oder in anderen Python- Implementierungen kann sich das Verhalten unterscheiden.
Verwende __del__ deshalb nicht als primären Mechanismus zum Schließen von
Dateien, Netzwerkverbindungen oder anderen externen Ressourcen.
Dafür sind besser geeignet:
- explizite
close()-Methoden, tryundfinally,- Context Manager mit
with.
Keine eigenen Dunder-Namen erfinden
Verwende nur dokumentierte Special Methods.
Eine Methode wie:
def __speichere_dungeon__(self):
...
wirkt vielleicht passend, besitzt für Python aber keine definierte Bedeutung.
Namen mit doppelten Unterstrichen an beiden Seiten sind für Pythons Datenmodell und mögliche zukünftige Erweiterungen vorgesehen.
Für eigene Operationen verwendest Du normale Namen:
def speichere_dungeon(self):
...
Stolperfallen
-
Die Abbildung zu wörtlich verstehen:
len(x)ist nicht einfach ein normaler Aufruf vonx.__len__(). Implizite Special Methods werden grundsätzlich auf der Klasse gesucht. -
Special Methods nur an einer Instanz setzen: Ein
objekt.__len__ = ...reicht fürlen(objekt)nicht aus. -
Dunder Methods direkt aufrufen: Nutze normalerweise
len(x),repr(x),x + yoderelement in x, damit Python alle Fallback-Regeln anwenden kann. -
__len__einen negativen oder ungeeigneten Wert zurückgeben lassen:__len__muss eine nicht negative Ganzzahl liefern. -
__bool__unnötig ergänzen: Existiert__len__, ergibt sich die übliche Truthiness für Collections bereits automatisch. -
__iter__und__next__verwechseln: Ein Container erzeugt normalerweise über__iter__einen neuen Iterator. Der Iterator selbst implementiert__next__. -
In
__iter__immerselfzurückgeben: Das ist nur richtig, wenn das Objekt selbst ein Iterator ist. -
Bei
__getitem__Slices vergessen: Der übergebene Wert kann ein Integer oder einslice-Objekt sein. -
Den Iterations-Fallback über
__getitem__unbegrenzt laufen lassen: Eine sequenzartige Implementierung muss bei ungültigen IndizesIndexErrorauslösen. -
NotImplementedmitNotImplementedErrorverwechseln: Operatoren und Vergleiche geben bei unbekannten OperandentypenNotImplementedzurück. -
Bei fremden Vergleichstypen
Falsezurückgeben: Häufig istNotImplementedbesser, damit Python weitere Vergleichsregeln anwenden kann. -
__eq__ohne Gedanken an__hash__definieren: Mutable Objekte mit inhaltsbasierter Equality sollten normalerweise unhashable bleiben. -
Einen Hash aus mutablem State bilden: Ändert sich der Hashwert eines Dictionary-Schlüssels oder Set-Elements, kann die Collection das Objekt nicht mehr zuverlässig finden.
-
Operatoren mit überraschender Bedeutung versehen: Eine benannte Methode ist besser als ein kreativer, aber unverständlicher Operator.
-
__str__oder__repr__nichts zurückgeben lassen: Beide Methoden müssen einen String liefern. -
Ressourcen über
__del__verwalten: Verwende für zuverlässiges Cleanup einen Context Manager oder eine explizite Methode. -
Eigene Dunder-Namen erfinden: Verwende ausschließlich dokumentierte Special Methods.
Kompakte Übersicht
| Gewünschtes Verhalten | Special Method |
|---|---|
| Objekt 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__ |
| Addition | __add__ |
| reflektierte Addition | __radd__ |
| In-place-Addition | __iadd__ |
| Equality | __eq__ |
Sortierung mit < |
__lt__ |
| Hashwert | __hash__ |
| Objekt wie Funktion aufrufen | __call__ |
| Context Manager | __enter__, __exit__ |
Fazit
Dunder Methods sind keine Sammlung kryptischer Tricks. Sie bilden die Schnittstelle zwischen einer Klasse und Pythons Sprachsyntax.
Mit einigen wenigen Methoden kann sich ein eigenes Objekt natürlich in die Sprache einfügen:
len(inventar)
if inventar:
...
gegenstand in inventar
inventar[0]
inventar[:2]
for gegenstand in inventar:
...
inventar_1 + inventar_2
print(inventar)
Die interne Implementierung bleibt dabei verborgen. Benutzer der Klasse arbeiten mit vertrauten Python-Operationen.
Entscheidend ist nicht, möglichst viele Special Methods zu implementieren. Eine Klasse sollte nur die Protokolle unterstützen, die zu ihrer fachlichen Bedeutung passen und deren Verhalten für Leser erwartbar ist.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.