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. Der offizielle Begriff lautet Special Methods.

Hinter vielen vertrauten Operationen steckt eine solche Methode:

  • 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 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, await oder 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:

  1. Python verwendet __bool__, falls vorhanden.
  2. Andernfalls verwendet es __len__.
  3. 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:

  1. über __iter__ zu iterieren,
  2. als älteren Fallback über __getitem__ ab Index 0 zu 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 @dataclass die 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,
  • try und finally,
  • 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 von x.__len__(). Implizite Special Methods werden grundsätzlich auf der Klasse gesucht.

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

  • Dunder Methods direkt aufrufen: Nutze normalerweise len(x), repr(x), x + y oder element 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__ immer self zurückgeben: Das ist nur richtig, wenn das Objekt selbst ein Iterator ist.

  • Bei __getitem__ Slices vergessen: Der übergebene Wert kann ein Integer oder ein slice-Objekt sein.

  • Den Iterations-Fallback über __getitem__ unbegrenzt laufen lassen: Eine sequenzartige Implementierung muss bei ungültigen Indizes IndexError auslösen.

  • NotImplemented mit NotImplementedError verwechseln: Operatoren und Vergleiche geben bei unbekannten Operandentypen NotImplemented zurück.

  • Bei fremden Vergleichstypen False zurückgeben: Häufig ist NotImplemented besser, 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.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!