cat posts/dataclasses.md
Dataclasses: Klassen ohne Boilerplate
@dataclass erzeugt Initialisierung, Repräsentation und Vergleiche automatisch. Mit Fields, Default Factories, frozen, slots und __post_init__ werden Datenklassen kompakt und trotzdem flexibel.
In Teil 7 der Python-Serie haben wir
Spieler, Raum und Gegenstand als Klassen modelliert. Dabei mussten wir
jedes Attribut in __init__ entgegennehmen und anschließend am Objekt speichern:
class Gegenstand:
def __init__(self, name, beschreibung=""):
self.name = name
self.beschreibung = beschreibung
Dazu kam eine __repr__-Methode, die im Wesentlichen nur dieselben Attribute
wieder auflistete:
def __repr__(self):
return (
f"Gegenstand("
f"name={self.name!r}, "
f"beschreibung={self.beschreibung!r}"
f")"
)
Solche wiederkehrenden mechanischen Teile nennt man Boilerplate. Sie sind notwendig, enthalten aber kaum eigentliche Programmlogik.
Für Klassen, deren Hauptaufgabe darin besteht, strukturierten State zu speichern,
bringt Python deshalb das Standardmodul dataclasses mit.
Die erste Dataclass
Eine Dataclass entsteht mit dem Decorator @dataclass:
from dataclasses import dataclass
@dataclass
class Gegenstand:
name: str
beschreibung: str = ""
Mehr brauchen wir für die grundlegende Klasse nicht.
Python erzeugt unter anderem eine passende __init__-Methode. Dadurch können
wir die Klasse ganz normal verwenden:
fackel = Gegenstand(
name="Fackel",
beschreibung="Eine rußige, aber noch brauchbare Fackel.",
)
print(fackel.name)
print(fackel.beschreibung)
Ausgabe:
Fackel
Eine rußige, aber noch brauchbare Fackel.
Der generierte Constructor entspricht für dieses Beispiel ungefähr:
def __init__(self, name: str, beschreibung: str = ""):
self.name = name
self.beschreibung = beschreibung
Genau dieser immer gleiche Code fällt weg.
Was @dataclass eigentlich macht
Die Schreibweise
@dataclass
class Gegenstand:
...
ist ein Decorator-Aufruf.
Vereinfacht kannst Du Dir das so vorstellen:
class Gegenstand:
...
Gegenstand = dataclass(Gegenstand)
dataclass() untersucht die Klassendefinition und erzeugt abhängig von der
Konfiguration verschiedene Dunder Methods.
Ohne weitere Optionen gelten unter Python 3.14 diese Defaults:
@dataclass(
init=True,
repr=True,
eq=True,
order=False,
unsafe_hash=False,
frozen=False,
match_args=True,
kw_only=False,
slots=False,
weakref_slot=False,
)
class Gegenstand:
...
Für die meisten Dataclasses brauchst Du davon zunächst nichts ausdrücklich anzugeben:
@dataclass
class Gegenstand:
...
Eine Besonderheit schauen wir uns später genauer an: Mit slots=True gibt der
Decorator technisch eine neu erzeugte Klasse zurück, statt lediglich die
vorhandene Klasse zu verändern.
Fields statt handgeschriebenem __init__
Die annotierten Attribute einer Dataclass heißen Fields:
@dataclass
class Gegenstand:
name: str
beschreibung: str = ""
Diese Klasse besitzt zwei Fields:
name
beschreibung
name hat keinen Default-Wert und muss deshalb beim Erzeugen eines Objekts
angegeben werden:
fackel = Gegenstand("Fackel")
beschreibung besitzt dagegen den Default "":
schluessel = Gegenstand(
"Schlüssel",
"Ein schwerer Schlüssel mit rostigen Zähnen.",
)
Die Reihenfolge der Fields bestimmt grundsätzlich auch die Reihenfolge der
positionalen Parameter im generierten __init__.
Fields brauchen eine Typannotation
@dataclass erkennt Fields in erster Linie an einer Typannotation:
@dataclass
class Gegenstand:
name: str
wert: int
Eine normale Zuweisung ohne Annotation wird nicht automatisch zum Field:
@dataclass
class Gegenstand:
kategorie = "Allgemein"
name: str = "Unbekannt"
kategorie bleibt ein normales Klassenattribut. Es erscheint deshalb auch nicht
als Parameter im generierten Constructor:
Gegenstand(kategorie="Werkzeug")
führt zu einem TypeError.
ClassVar: ausdrücklich kein Field
Auch ein annotiertes Attribut kann ausdrücklich als Klassenvariable markiert werden:
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class Gegenstand:
maximale_namenslaenge: ClassVar[int] = 50
name: str
beschreibung: str = ""
maximale_namenslaenge gehört zur Klasse und nicht zu jedem einzelnen Objekt.
Es taucht deshalb weder als Constructor-Parameter noch in den Dataclass-Fields auf.
print(Gegenstand.maximale_namenslaenge)
Ausgabe:
50
Typannotationen validieren nichts
Eine Dataclass verwendet die Annotationen zum Erkennen ihrer Fields. Sie kontrolliert aber nicht automatisch, ob die übergebenen Werte tatsächlich zu diesen Typen passen.
@dataclass
class Gegenstand:
name: str
wert: int = 0
Auch das hier erzeugt zur Laufzeit zunächst ein Objekt:
gegenstand = Gegenstand(
name=123,
wert="teuer",
)
Die Annotationen helfen Editoren, Type Checkern, Ruff und Menschen beim Lesen des Codes. Sie sind aber keine automatische Runtime-Validierung.
Wenn Deine Klasse Werte zur Laufzeit kontrollieren soll, musst Du diese Regeln selbst implementieren oder ein dafür vorgesehenes Werkzeug verwenden.
__repr__ gibt es gratis dazu
Standardmäßig erzeugt @dataclass auch eine __repr__-Methode:
fackel = Gegenstand(
name="Fackel",
beschreibung="Eine rußige Fackel.",
)
print(repr(fackel))
Ausgabe:
Gegenstand(name='Fackel', beschreibung='Eine rußige Fackel.')
Auch:
print(fackel)
zeigt diese Darstellung, solange die Klasse kein eigenes __str__ besitzt.
Der Grund: print() verwendet str(), und die Standardimplementierung von
object.__str__() greift auf __repr__() zurück.
@dataclass erzeugt selbst also __repr__, aber kein eigenes __str__.
Für eine menschenlesbare Darstellung kannst Du weiterhin eine Methode ergänzen:
@dataclass
class Gegenstand:
name: str
beschreibung: str = ""
def __str__(self):
return self.name
Jetzt ergibt:
fackel = Gegenstand("Fackel")
print(fackel)
print(repr(fackel))
die Ausgabe:
Fackel
Gegenstand(name='Fackel', beschreibung='')
Die automatisch erzeugte __repr__ bleibt erhalten.
Equality nach Field-Werten
Eine weitere automatisch erzeugte Methode ist __eq__.
Bei einer normalen Klasse ohne eigene Equality bedeutet == im einfachsten
Fall Objektidentität. Zwei verschiedene Instanzen gelten also nicht allein
deshalb als gleich, weil sie dieselben Attribute besitzen.
Dataclasses vergleichen dagegen standardmäßig ihre Fields:
fackel_1 = Gegenstand("Fackel")
fackel_2 = Gegenstand("Fackel")
schluessel = Gegenstand("Schlüssel")
print(fackel_1 == fackel_2)
print(fackel_1 == schluessel)
Ausgabe:
True
False
fackel_1 und fackel_2 sind zwei unterschiedliche Objekte, ihre Field-Werte
sind aber gleich.
Dabei müssen beide Vergleichsobjekte vom identischen konkreten Typ sein. Eine Unterklasse mit denselben Fields gilt bei der automatisch erzeugten Equality also nicht einfach als gleich.
Du kannst diese automatische Methode abschalten:
@dataclass(eq=False)
class Spieler:
name: str
Dann erzeugt dataclass keine eigene __eq__. Ob anschließend
identitätsbasiert oder anders verglichen wird, hängt von einer eventuell
geerbten __eq__-Methode ab. Bei einer direkten Unterklasse von object landet
man wieder beim üblichen Identitätsverhalten.
Für eine Spielfigur kann das sinnvoll sein: Zwei Spieler mit demselben Namen, denselben HP und demselben Inventar müssen nicht zwingend dieselbe Figur darstellen.
Fields mit und ohne Default
Wie bei normalen Funktionsparametern dürfen Pflichtparameter nicht hinter Parametern mit Default-Wert stehen.
Das funktioniert:
@dataclass
class Gegenstand:
name: str
wert: int = 0
Das hier dagegen nicht:
@dataclass
class Gegenstand:
wert: int = 0
name: str
Python könnte daraus keinen normalen Constructor wie:
def __init__(self, wert=0, name):
...
erzeugen.
Die Dataclass-Definition endet deshalb mit einem TypeError.
Diese Regel gilt auch über Dataclass-Vererbung hinweg. Ein Field ohne Default in einer Unterklasse kann also mit Default-Fields aus einer Basisklasse kollidieren.
Keyword-only Fields können in solchen Fällen ebenfalls hilfreich sein. Dazu kommen wir gleich noch.
Mutable Defaults und default_factory
Für ein Spielerinventar liegt diese Definition zunächst nahe:
@dataclass
class Spieler:
name: str
inventar: list[str] = []
Bei normalen Funktions-Defaults wäre genau das ein Klassiker: Mehrere Objekte würden dieselbe Liste teilen.
Aktuelle Python-Versionen lassen diese Dataclass deshalb gar nicht erst zu:
ValueError: mutable default ... for field inventar is not allowed:
use default_factory
Seit Python 3.11 prüft dataclasses dabei nicht nur speziell auf list, dict
und set. Unhashable Default-Werte werden allgemein als Näherung für mutable
Objekte abgelehnt.
Die korrekte Lösung ist:
from dataclasses import dataclass, field
@dataclass
class Spieler:
name: str
inventar: list[str] = field(default_factory=list)
default_factory=list bedeutet:
Wenn beim Erzeugen des Objekts kein Wert übergeben wird, rufe
list()auf.
Dadurch bekommt jeder Spieler eine eigene Liste:
karl = Spieler("Karl")
lara = Spieler("Lara")
karl.inventar.append("Fackel")
print(karl.inventar)
print(lara.inventar)
Ausgabe:
['Fackel']
[]
Beliebige Default Factories
Eine Default Factory kann jeder Callable sein, der ohne Argumente aufgerufen werden kann:
@dataclass
class Spieler:
name: str
inventar: list[str] = field(
default_factory=lambda: ["Brot"]
)
Jetzt startet jeder Spieler mit einer eigenen Liste:
karl = Spieler("Karl")
lara = Spieler("Lara")
print(karl.inventar)
print(lara.inventar)
Ausgabe:
['Brot']
['Brot']
Dasselbe Prinzip gilt für Dictionaries und Sets:
@dataclass
class Raum:
ausgaenge: dict[str, str] = field(default_factory=dict)
besucher: set[str] = field(default_factory=set)
Wichtig ist, dass Du die Factory selbst übergibst:
default_factory=list
und nicht ihr Ergebnis:
default_factory=list() # falsch
dataclasses soll die Funktion schließlich für jede neue Instanz erneut
aufrufen.
Fields mit field(...) konfigurieren
field() kann weit mehr als nur Default Factories definieren.
Einige der wichtigsten Optionen sind:
| Option | Bedeutung |
|---|---|
default |
fester Default-Wert |
default_factory |
Callable für einen neuen Default-Wert |
init |
Field als Parameter in __init__ verwenden |
repr |
Field in der automatisch erzeugten repr anzeigen |
compare |
Field für Equality und Ordering berücksichtigen |
hash |
Field beim generierten Hash berücksichtigen |
kw_only |
Field nur als Keyword Argument erlauben |
metadata |
Metadaten für externe Tools hinterlegen |
doc |
Dokumentation für das Field, seit Python 3.14 |
Ein Beispiel:
@dataclass
class Spieler:
name: str
passwort: str = field(repr=False, compare=False)
hp: int = field(init=False)
inventar: list[str] = field(
default_factory=list,
repr=False,
)
def __post_init__(self):
self.hp = 100
passwort gehört weiterhin zum Constructor:
spieler = Spieler(
name="Karl",
passwort="geheim",
)
wird aber weder in repr(spieler) angezeigt noch bei der automatisch erzeugten
Equality berücksichtigt.
inventar erscheint ebenfalls nicht in repr().
hp ist dagegen überhaupt kein Constructor-Parameter:
Spieler(
name="Karl",
passwort="geheim",
hp=50,
)
ist ungültig.
Den Wert müssen wir deshalb anderweitig initialisieren.
__post_init__: direkt nach dem Constructor
Wenn @dataclass den Constructor erzeugt, kannst Du dessen Ende mit
__post_init__ ergänzen:
@dataclass
class Spieler:
name: str
max_hp: int = 100
hp: int = field(init=False)
def __post_init__(self):
self.hp = self.max_hp
Der Ablauf entspricht vereinfacht:
def __init__(self, name, max_hp=100):
self.name = name
self.max_hp = max_hp
self.__post_init__()
Damit beginnt jeder Spieler mit vollen HP:
spieler = Spieler("Karl", max_hp=120)
print(spieler.hp)
Ausgabe:
120
__post_init__ eignet sich unter anderem für berechnete Fields, Normalisierung,
Validierung und Werte, die von mehreren Constructor-Argumenten abhängen.
Wichtig: __post_init__ wird automatisch nur von einem durch Dataclass
generierten __init__ aufgerufen. Schreibst Du selbst einen Constructor oder
verwendest init=False, musst Du Dich auch selbst um den Aufruf kümmern.
Werte normalisieren und validieren
__post_init__ ist ein guter Ort für einfache fachliche Regeln:
@dataclass
class Spieler:
name: str
max_hp: int = 100
gold: int = 0
hp: int = field(init=False)
def __post_init__(self):
self.name = self.name.strip()
if not self.name:
self.name = "Unbekannt"
if self.max_hp <= 0:
raise ValueError("max_hp muss größer als 0 sein.")
if self.gold < 0:
raise ValueError("gold darf nicht negativ sein.")
self.hp = self.max_hp
Die Klasse verhindert damit einige ungültige Startzustände.
Sie validiert aber weiterhin nur das, was wir ausdrücklich programmieren. Aus:
max_hp: int
wird auch mit __post_init__ nicht automatisch ein universeller Runtime-Type
Check.
InitVar: nur während der Initialisierung
Manchmal braucht __post_init__ einen zusätzlichen Wert, der gar kein
dauerhaftes Field werden soll.
Dafür gibt es InitVar:
from dataclasses import InitVar, dataclass, field
@dataclass
class Spieler:
name: str
max_hp: int = 100
start_hp: InitVar[int | None] = None
hp: int = field(init=False)
def __post_init__(self, start_hp):
if start_hp is None:
self.hp = self.max_hp
else:
self.hp = min(max(start_hp, 0), self.max_hp)
start_hp erscheint im generierten Constructor:
spieler = Spieler(
name="Karl",
max_hp=100,
start_hp=50,
)
und wird an __post_init__ weitergereicht.
Es wird aber nicht als Instanz-Field gespeichert:
print(spieler.__dict__)
enthält start_hp nicht.
Auch dataclasses.fields() führt ein InitVar nicht als Field auf.
Eine kleine Feinheit: Weil start_hp in unserem Beispiel einen Klassen-Default
von None besitzt, kann ein Zugriff wie:
print(spieler.start_hp)
trotzdem None ergeben. Das ist dann der Klassenwert, nicht der beim Constructor
übergebene Wert 50.
Darauf solltest Du Dich nicht als gespeicherten State verlassen.
Ein InitVar ist Input für die Initialisierung und sonst nichts.
Keyword-only Dataclasses
Mit vielen positionalen Argumenten werden Constructor-Aufrufe schnell undurchsichtig:
raum = Raum(
"Halle",
"Eine dunkle Halle.",
{"norden": "bibliothek"},
[],
)
Ohne Klassendefinition ist kaum zu erkennen, wofür die letzten beiden Argumente stehen.
Seit Python 3.10 kannst Du alle Fields einer Dataclass als Keyword-only deklarieren:
@dataclass(kw_only=True)
class Raum:
name: str
beschreibung: str
ausgaenge: dict[str, str] = field(default_factory=dict)
gegenstaende: list[Gegenstand] = field(default_factory=list)
Nun ist der Aufruf ausführlicher, aber eindeutig:
raum = Raum(
name="Halle",
beschreibung="Eine dunkle Halle.",
ausgaenge={"norden": "bibliothek"},
gegenstaende=[Gegenstand("Fackel")],
)
Diese Variante ist nicht mehr erlaubt:
Raum("Halle", "Eine dunkle Halle.")
Einzelne Fields als Keyword-only
Du kannst auch nur ausgewählte Fields entsprechend markieren:
@dataclass
class Gegenstand:
name: str
beschreibung: str = field(default="", kw_only=True)
Damit funktioniert:
Gegenstand(
"Fackel",
beschreibung="Eine rußige Fackel.",
)
aber nicht:
Gegenstand(
"Fackel",
"Eine rußige Fackel.",
)
KW_ONLY als Trennlinie
Alternativ gibt es das Pseudo-Field KW_ONLY:
from dataclasses import KW_ONLY, dataclass
@dataclass
class Gegenstand:
name: str
_: KW_ONLY
beschreibung: str = ""
wert: int = 0
Alle Fields hinter _ werden Keyword-only:
Gegenstand(
"Fackel",
beschreibung="Eine rußige Fackel.",
wert=10,
)
Das _ selbst wird weder zu einem echten Field noch zu einem
Constructor-Argument.
frozen=True: Zuweisungen verhindern
Für ein Value Object wie einen Gegenstand kann es sinnvoll sein, spätere Änderungen zu verhindern:
@dataclass(frozen=True)
class Gegenstand:
name: str
beschreibung: str = ""
Das Objekt lässt sich normal erzeugen:
fackel = Gegenstand("Fackel")
Eine spätere Zuweisung führt dagegen zu einem FrozenInstanceError:
fackel.name = "Magische Fackel"
Auch ein Löschen des Fields wird blockiert.
frozen=True emuliert damit ein read-only Objekt, macht Python-Objekte aber
nicht auf magische Weise vollständig immutable.
frozen ist nur flach
Eine eingefrorene Dataclass kann weiterhin mutable Objekte enthalten:
@dataclass(frozen=True)
class Gruppe:
mitglieder: list[str] = field(default_factory=list)
Diese Zuweisung ist verboten:
gruppe.mitglieder = ["Karl"]
Der Inhalt der vorhandenen Liste lässt sich dagegen weiterhin ändern:
gruppe.mitglieder.append("Karl")
Wenn auch das Field selbst immutable sein soll, brauchst Du einen passenden Datentyp:
@dataclass(frozen=True)
class Gruppe:
mitglieder: tuple[str, ...] = ()
frozen=True schützt also die Field-Zuweisungen, nicht rekursiv jedes Objekt
dahinter.
Equality und Hashing
Sobald Objekte als Dictionary-Schlüssel oder Elemente eines Sets verwendet
werden sollen, kommt __hash__ ins Spiel.
Eine normale mutable Dataclass mit Equality ist standardmäßig nicht hashable:
@dataclass
class Gegenstand:
name: str
Dieser Code scheitert:
gegenstaende = {Gegenstand("Fackel")}
mit:
TypeError: cannot use 'Gegenstand' as a set element (unhashable type: 'Gegenstand')
Das Verhalten hängt hauptsächlich von eq und frozen ab:
eq |
frozen |
Standardverhalten für __hash__ |
|---|---|---|
True |
False |
nicht hashable |
True |
True |
Hash wird erzeugt |
False |
egal | geerbtes Hash-Verhalten bleibt |
Eine frozen Dataclass eignet sich deshalb häufig für Value Objects:
@dataclass(frozen=True)
class Gegenstand:
name: str
beschreibung: str = ""
Nun kann ein Gegenstand grundsätzlich in einem Set verwendet werden:
gegenstaende = {
Gegenstand("Fackel"),
Gegenstand("Schlüssel"),
}
Allerdings müssen auch die vom Hash verwendeten Field-Werte selbst hashable sein.
Diese Klasse erhält zwar eine generierte __hash__-Methode:
@dataclass(frozen=True)
class Gruppe:
mitglieder: list[str]
aber:
hash(Gruppe(["Karl"]))
scheitert trotzdem, weil eine Liste nicht hashable ist.
unsafe_hash=True
Mit:
@dataclass(unsafe_hash=True)
class Spieler:
name: str
kannst Du die Erzeugung einer Hash-Methode erzwingen.
Der Name der Option ist absichtlich deutlich.
Wenn sich ein hash-relevantes Field verändert, während das Objekt als Dictionary-Schlüssel oder in einem Set steckt, kann es anschließend praktisch nicht mehr zuverlässig gefunden werden.
unsafe_hash=True ist deshalb kein allgemeiner Schalter für:
Ich möchte mein Objekt in ein Set legen.
Die Option ist für Spezialfälle gedacht, bei denen Du selbst garantieren kannst, dass die für Equality und Hash relevante logische Identität stabil bleibt.
Sortierung mit order=True
Standardmäßig erzeugt @dataclass Equality, aber keine
Kleiner-/Größer-Vergleiche.
Mit:
@dataclass(order=True)
class RanglistenEintrag:
punkte: int
name: str
werden zusätzlich __lt__, __le__, __gt__ und __ge__ erzeugt.
Damit kannst Du die Objekte sortieren:
rangliste = [
RanglistenEintrag(120, "Karl"),
RanglistenEintrag(90, "Lara"),
RanglistenEintrag(120, "Anna"),
]
print(sorted(rangliste))
Ausgabe:
[RanglistenEintrag(punkte=90, name='Lara'), RanglistenEintrag(punkte=120, name='Anna'), RanglistenEintrag(punkte=120, name='Karl')]
Die Fields werden in ihrer definierten Reihenfolge betrachtet:
- zuerst
punkte, - bei Gleichstand
name.
Ein Field kannst Du aus Equality und Ordering herausnehmen:
@dataclass(order=True)
class RanglistenEintrag:
punkte: int
name: str = field(compare=False)
Jetzt entscheidet ausschließlich punkte.
order=True setzt außerdem eq=True voraus. Eine Kombination aus:
@dataclass(order=True, eq=False)
führt zu einem ValueError.
Ordering sollte nur aktiviert werden, wenn die Reihenfolge der Fields tatsächlich eine sinnvolle fachliche Ordnung beschreibt.
slots=True
Normale Python-Objekte besitzen häufig ein __dict__, in dem ihre Attribute
gespeichert werden.
Dadurch kann man auch nachträglich neue Attribute hinzufügen:
gegenstand.neues_attribut = 123
Mit:
@dataclass(slots=True)
class Gegenstand:
name: str
beschreibung: str = ""
erzeugt dataclass passende __slots__.
Bei einer einfachen Klasse bedeutet das normalerweise:
- Die Instanzen benötigen weniger Speicher.
- Es können nicht beliebig neue Attribute angelegt werden.
Ein Tippfehler fällt dadurch sofort auf:
gegenstand.beschreibunng = "Tippfehler"
statt unbemerkt ein neues Attribut anzulegen, erhältst Du einen
AttributeError.
Der Speicherunterschied lohnt sich vor allem bei sehr vielen Instanzen. Für drei Räume und zwei Gegenstände ist er irrelevant.
Die begrenzte Attributmenge kann trotzdem angenehm sein.
slots=True ist intern ein Sonderfall
Ohne Slots verändert @dataclass im Wesentlichen die vorhandene Klasse und
gibt sie zurück.
Bei:
@dataclass(slots=True)
class Gegenstand:
...
wird dagegen eine neue Klasse mit den passenden Slots erzeugt und vom Decorator zurückgegeben.
Im normalen Anwendungscode merkst Du davon kaum etwas. Bei Metaprogramming,
__init_subclass__, komplexer Vererbung oder eigenen Class Decorators kann der
Unterschied aber relevant werden.
Definierst Du bereits selbst __slots__, darfst Du nicht zusätzlich
slots=True verwenden.
weakref_slot=True
Slot-Klassen besitzen nicht automatisch den für Weak References benötigten
__weakref__-Slot.
Wenn Du Instanzen einer Dataclass mit weakref.ref() verwenden möchtest, kannst
Du seit Python 3.11 schreiben:
@dataclass(
slots=True,
weakref_slot=True,
)
class Gegenstand:
name: str
weakref_slot=True darf nur zusammen mit slots=True verwendet werden.
Für normale Anwendungsmodelle brauchst Du diese Option selten. Sie ist eher relevant für Caches, Frameworks und andere Strukturen, die Weak References verwenden.
Optionen kombinieren
Die Decorator-Optionen lassen sich kombinieren:
@dataclass(
frozen=True,
slots=True,
)
class Gegenstand:
name: str
beschreibung: str = ""
Damit bekommen wir ein kompaktes Value Object mit:
- generiertem
__init__, - generiertem
__repr__, - Equality nach Fields,
- normalerweise einem passenden Hash,
- blockierten Field-Zuweisungen,
- Slots.
Für einen Raum gelten andere Anforderungen:
@dataclass(
slots=True,
kw_only=True,
)
class Raum:
name: str
beschreibung: str
ausgaenge: dict[str, str] = field(default_factory=dict)
gegenstaende: list[Gegenstand] = field(default_factory=list)
Ein Raum bleibt mutable und unhashable, seine Constructor-Parameter müssen aber mit Namen angegeben werden.
Welche Optionen sinnvoll sind, hängt vom Modell ab. Es gibt keine universelle „beste“ Dataclass-Konfiguration.
field(doc=...) seit Python 3.14
Python 3.14 ergänzt bei field() die Option doc.
Damit kann ein Field eine eigene kurze Dokumentation erhalten:
@dataclass
class Spieler:
name: str = field(
doc="Anzeigename des Spielers."
)
hp: int = field(
default=100,
doc="Aktuelle Lebenspunkte."
)
Diese Dokumentation ist Metadaten am Field-Objekt. Sie wird nicht automatisch
als Kommentar in repr() oder ähnlichen Ausgaben angezeigt.
Abrufen kannst Du die Field-Informationen über fields():
from dataclasses import fields
for eintrag in fields(Spieler):
print(eintrag.name, eintrag.doc)
Das ist vor allem für Tools interessant, die Dataclasses untersuchen und daraus beispielsweise Dokumentation, Formulare oder andere Strukturen erzeugen.
Für eine normale kleine Dataclass reichen gewöhnliche Kommentare und Docstrings oft völlig aus.
Dataclasses und Vererbung
Dataclasses können ganz normal voneinander erben:
@dataclass
class Wesen:
name: str
hp: int = 100
@dataclass
class Spieler(Wesen):
gold: int = 0
Spieler übernimmt die Fields der Basisklasse.
spieler = Spieler(
name="Karl",
hp=80,
gold=20,
)
Beim Erzeugen der abgeleiteten Dataclass sammelt Python die Fields der Dataclass-Basisklassen entlang der MRO und ergänzt danach die neuen Fields.
Eine Unterklasse kann ein geerbtes Field auch überschreiben:
@dataclass
class Spieler(Wesen):
hp: int = 150
gold: int = 0
Der Name bleibt an seiner Position in der Field-Reihenfolge, Typ und Default stammen aber aus der abgeleiteten Klasse.
Der generierte Constructor ruft normale Base-__init__ nicht auf
Ein wichtiges Detail betrifft Basisklassen, die keine Dataclasses sind:
class Protokolliert:
def __init__(self):
print("Basisklasse initialisiert")
@dataclass
class Spieler(Protokolliert):
name: str
Der automatisch generierte Spieler.__init__() ruft
Protokolliert.__init__() nicht von selbst auf.
Wenn das notwendig ist, kannst Du es in __post_init__ erledigen:
@dataclass
class Spieler(Protokolliert):
name: str
def __post_init__(self):
super().__init__()
Bei einer Dataclass-Basisklasse ist das anders: Deren Fields werden bereits
in den generierten Constructor der Unterklasse aufgenommen. Du musst ihren
generierten __init__ normalerweise nicht zusätzlich aufrufen.
Dataclasses dürfen Verhalten enthalten
Eine Dataclass ist weiterhin eine ganz normale Python-Klasse.
Sie kann beliebig viele eigene Methoden besitzen:
@dataclass
class Spieler:
name: str
hp: int = 100
max_hp: int = 100
def erleide_schaden(self, menge):
self.hp = max(self.hp - menge, 0)
def heile(self, menge):
self.hp = min(self.hp + menge, self.max_hp)
def ist_besiegt(self):
return self.hp <= 0
Dataclasses sind also nicht auf passive Structs beschränkt.
Der Decorator nimmt Dir mechanische Methoden ab. Die tatsächliche Programmlogik bleibt ganz normal Bestandteil der Klasse.
Der Dungeon mit Dataclasses
Jetzt können wir die Klassen aus Teil 7 auf Dataclasses umstellen.
Gegenstand eignet sich als kleines unveränderliches Value Object. Spieler und
Raum bleiben mutable, weil sich HP, Inventar und die Gegenstände eines Raumes
während des Spiels verändern.
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Gegenstand:
"""Ein Gegenstand, der in einem Raum oder Inventar liegen kann."""
name: str
beschreibung: str = ""
def __str__(self):
return self.name
def untersuche(self):
if self.beschreibung:
return self.beschreibung
return f"An {self.name} fällt Dir nichts Besonderes auf."
@dataclass(slots=True, eq=False)
class Spieler:
"""Verwaltet den State und das Verhalten des Spielers."""
name: str
max_hp: int = 100
gold: int = 0
hp: int = field(init=False)
inventar: list[Gegenstand] = field(
default_factory=list,
repr=False,
)
def __post_init__(self):
self.name = self.name.strip() or "Unbekannt"
if self.max_hp <= 0:
raise ValueError("max_hp muss größer als 0 sein.")
if self.gold < 0:
raise ValueError("gold darf nicht negativ sein.")
self.hp = self.max_hp
def nimm(self, gegenstand):
self.inventar.append(gegenstand)
def erleide_schaden(self, menge):
self.hp = max(self.hp - menge, 0)
def heile(self, menge):
self.hp = min(self.hp + menge, self.max_hp)
def ist_besiegt(self):
return self.hp <= 0
def hat_gegenstand(self, name):
gesuchter_name = name.strip().lower()
return any(
gegenstand.name.lower() == gesuchter_name
for gegenstand in self.inventar
)
def zeige_inventar(self):
if not self.inventar:
print("Dein Inventar ist leer.")
return
print("Inventar:")
for nummer, gegenstand in enumerate(self.inventar, start=1):
print(f" {nummer}) {gegenstand}")
def __str__(self):
return (
f"{self.name} | "
f"HP: {self.hp}/{self.max_hp} | "
f"Gold: {self.gold} | "
f"Inventar: {len(self.inventar)}"
)
@dataclass(slots=True, kw_only=True)
class Raum:
"""Ein Raum mit Beschreibung, Ausgängen und Gegenständen."""
name: str
beschreibung: str
ausgaenge: dict[str, str] = field(default_factory=dict)
gegenstaende: list[Gegenstand] = field(default_factory=list)
def beschreibe(self):
print()
print(f"Ort: {self.name}")
print(self.beschreibung)
print("Ausgänge:")
for nummer, richtung in enumerate(self.ausgaenge, start=1):
print(f" {nummer}) {richtung}")
if self.gegenstaende:
print(
"Hier liegt:",
", ".join(str(g) for g in self.gegenstaende),
)
def zeige_gegenstaende(self):
if not self.gegenstaende:
print("Hier liegt nichts Brauchbares.")
return
print(
"Hier liegt:",
", ".join(str(g) for g in self.gegenstaende),
)
def hat_ausgang(self, richtung):
return richtung in self.ausgaenge
def ziel(self, richtung):
return self.ausgaenge.get(richtung)
def nimm_gegenstand(self, name):
gesuchter_name = name.strip().lower()
for gegenstand in self.gegenstaende:
if gegenstand.name.lower() == gesuchter_name:
self.gegenstaende.remove(gegenstand)
return gegenstand
return None
def erstelle_raeume():
"""Erstellt die Dungeon-Welt."""
fackel = Gegenstand(
name="Fackel",
beschreibung="Eine rußige, aber noch brauchbare Fackel.",
)
schluessel = Gegenstand(
name="Schlüssel",
beschreibung="Ein schwerer Eisenschlüssel mit rostigen Zähnen.",
)
return {
"halle": Raum(
name="Halle",
beschreibung=(
"Du stehst in einer dunklen Halle mit moderigem Geruch."
),
ausgaenge={
"norden": "bibliothek",
"osten": "krypta",
},
gegenstaende=[fackel],
),
"bibliothek": Raum(
name="Bibliothek",
beschreibung=(
"Staubige Regale voller zerfledderter Bücher umgeben Dich."
),
ausgaenge={
"sueden": "halle",
},
gegenstaende=[schluessel],
),
"krypta": Raum(
name="Krypta",
beschreibung=(
"Feuchtigkeit glänzt auf den Wänden der alten Krypta."
),
ausgaenge={
"westen": "halle",
},
),
}
def teile_befehl(eingabe):
"""Zerlegt den Input in ein Verb und ein optionales Argument."""
verb, _, argument = eingabe.strip().lower().partition(" ")
return verb, argument.strip()
def finde_raeume_mit_loot(raeume):
"""Gibt die Namen aller Räume mit Gegenständen zurück."""
return [
raum.name
for raum in raeume.values()
if raum.gegenstaende
]
def main():
"""Startet den Dungeon und führt den Game-Loop aus."""
raeume = erstelle_raeume()
print("Du stehst vor dem rostigen Tor eines vergessenen Verlieses.")
print("Ein kalter Luftzug weht Dir entgegen.")
name = input("Wie heißt Du, Abenteurer? ").strip()
gold = int(input("Wie viele Goldmünzen bringst Du mit? "))
spieler = Spieler(
name=name,
max_hp=100,
gold=gold,
)
ort = "halle"
besuchte_raeume = set()
raum_anzeigen = True
print()
print(f"Willkommen, {spieler.name}!")
print(
"Befehle: umsehen, nimm <Gegenstand>, inventar, status, "
"loot, geh <Richtung>, ende"
)
while True:
raum = raeume[ort]
if raum_anzeigen:
besuchte_raeume.add(ort)
raum.beschreibe()
raum_anzeigen = False
verb, argument = teile_befehl(input("\n> "))
if verb == "ende":
print("Du verlässt das Verlies. Bis zum nächsten Mal.")
break
elif verb == "umsehen":
raum.zeige_gegenstaende()
elif verb == "nimm":
if not argument:
print("Was möchtest Du nehmen?")
continue
gegenstand = raum.nimm_gegenstand(argument)
if gegenstand is None:
print(f"Hier liegt kein Gegenstand namens {argument}.")
continue
spieler.nimm(gegenstand)
print(f"Du nimmst {gegenstand}.")
print(gegenstand.untersuche())
elif verb == "inventar":
spieler.zeige_inventar()
elif verb == "status":
print(spieler)
print(f"Besuchte Räume: {len(besuchte_raeume)}")
elif verb == "loot":
raeume_mit_loot = finde_raeume_mit_loot(raeume)
text = (
", ".join(raeume_mit_loot)
if raeume_mit_loot
else "(keine)"
)
print("Räume mit Loot:", text)
elif verb == "geh":
if not argument:
print("In welche Richtung möchtest Du gehen?")
continue
if not raum.hat_ausgang(argument):
print(f"Du kannst nicht nach {argument} gehen.")
continue
ort = raum.ziel(argument)
raum_anzeigen = True
elif not argument and raum.hat_ausgang(verb):
ort = raum.ziel(verb)
raum_anzeigen = True
else:
print("Das verstehe ich nicht.")
print("Mögliche Ausgänge:", ", ".join(raum.ausgaenge))
print(
"Weitere Befehle: umsehen, nimm <Gegenstand>, "
"inventar, status, loot, geh <Richtung>, ende"
)
main()
Die eigentliche Spiellogik hat sich kaum verändert. Der große Unterschied steckt in den Klassendefinitionen.
Bei Gegenstand brauchen wir keinen handgeschriebenen Constructor und keine
eigene __repr__-Methode mehr:
@dataclass(frozen=True, slots=True)
class Gegenstand:
name: str
beschreibung: str = ""
def __str__(self):
return self.name
def untersuche(self):
...
Spieler verwendet eq=False, weil wir zwei Figuren nicht allein aufgrund
identischer Field-Werte gleichsetzen wollen.
Raum verwendet kw_only=True. Bei vier inhaltlich unterschiedlichen
Constructor-Argumenten finde ich:
Raum(
name="Halle",
beschreibung="...",
ausgaenge={...},
gegenstaende=[...],
)
wesentlich verständlicher als eine positionale Argumentliste.
Dataclasses und Pattern Matching
Dataclasses funktionieren direkt mit Structural Pattern Matching:
gegenstand = Gegenstand(
name="Fackel",
beschreibung="Eine rußige Fackel.",
)
match gegenstand:
case Gegenstand(name="Fackel"):
print("Du hast eine Lichtquelle gefunden.")
case Gegenstand(name=name):
print(f"Du hast {name} gefunden.")
Standardmäßig erzeugt @dataclass außerdem __match_args__. Darin landen die
nicht als Keyword-only markierten Parameter, die für positionale Class Patterns
verwendet werden können.
Deshalb funktioniert bei unserem Gegenstand auch:
match gegenstand:
case Gegenstand("Fackel", _):
print("Du hast eine Fackel gefunden.")
Keyword Patterns finde ich hier trotzdem deutlicher:
case Gegenstand(name="Fackel"):
Sie hängen beim Lesen nicht von der Reihenfolge der Fields ab.
Keyword-only Fields werden nicht in __match_args__ aufgenommen.
Mehr dazu findest Du in match/case: Strukturelles Pattern Matching.
Fields zur Laufzeit untersuchen
Mit fields() kannst Du die Definition einer Dataclass introspektieren:
from dataclasses import fields
for eintrag in fields(Gegenstand):
print(eintrag.name, eintrag.type)
Für unsere Klasse liefert das sinngemäß:
name <class 'str'>
beschreibung <class 'str'>
Ein Field enthält unter anderem Informationen über:
name
type
default
default_factory
init
repr
compare
hash
kw_only
metadata
und seit Python 3.14 auch:
doc
ClassVar und InitVar sind keine echten Dataclass-Fields und erscheinen
deshalb nicht in fields().
Dataclasses in Dictionaries umwandeln
Mit asdict() kannst Du eine Dataclass in ein Dictionary überführen:
from dataclasses import asdict
fackel = Gegenstand(
name="Fackel",
beschreibung="Eine rußige Fackel.",
)
daten = asdict(fackel)
print(daten)
Ausgabe:
{'name': 'Fackel', 'beschreibung': 'Eine rußige Fackel.'}
Bei verschachtelten Dataclasses arbeitet asdict() rekursiv:
@dataclass
class Inventar:
gegenstaende: list[Gegenstand] = field(default_factory=list)
Enthaltene Dataclasses werden ebenfalls in Dictionaries umgewandelt.
Auch Dictionaries, Listen und Tupel werden rekursiv durchlaufen. Andere
Objekte kopiert asdict() über copy.deepcopy().
Das ist ein wichtiger Unterschied zu einem einfachen flachen Auslesen der Fields.
Wenn Du nur einen shallow Snapshot möchtest, ist beispielsweise diese Variante geeigneter:
from dataclasses import fields
daten = {
eintrag.name: getattr(fackel, eintrag.name)
for eintrag in fields(fackel)
}
asdict() ist keine JSON-Serialisierung
Das Ergebnis von asdict() sieht oft schon nach etwas aus, das direkt in JSON
könnte.
Das gilt aber nicht automatisch für alle enthaltenen Werte.
Datentypen wie:
datetime
set
Enum
Path
sind je nach Fall weiterhin nicht direkt JSON-serialisierbar.
asdict() übersetzt die Struktur einer Dataclass. Es ist kein vollständiges
Serialisierungsformat.
Veränderte Kopien mit replace()
Bei einer frozen Dataclass können wir ein Field nicht direkt ändern:
fackel.name = "Magische Fackel"
Mit replace() lässt sich stattdessen eine neue Instanz erzeugen:
from dataclasses import replace
fackel = Gegenstand(
name="Fackel",
beschreibung="Eine rußige Fackel.",
)
magische_fackel = replace(
fackel,
name="Magische Fackel",
)
Das ursprüngliche Objekt bleibt erhalten:
print(fackel)
print(magische_fackel)
Ausgabe:
Fackel
Magische Fackel
replace() ruft den Constructor der Klasse mit den neuen Werten auf. Dadurch
wird auch __post_init__ erneut ausgeführt.
Vorsicht bei InitVar und init=False
replace() ist keine simple Kopie des internen Objektzustands.
Besitzt eine Dataclass ein InitVar ohne Default, musst Du dessen Wert beim
Aufruf von replace() erneut angeben, weil der neue Constructor ihn wieder
benötigt.
Noch wichtiger sind Fields mit:
field(init=False)
Solche Fields dürfen nicht in changes übergeben werden:
replace(spieler, hp=50)
führt bei einem hp mit init=False zu einem Fehler.
Außerdem wird ein init=False-Field nicht einfach vom alten Objekt auf das neue
kopiert. Es wird so initialisiert, wie es der neue Constructor beziehungsweise
__post_init__ vorsieht.
Nehmen wir:
@dataclass
class Rechteck:
breite: int
hoehe: int
flaeche: int = field(init=False)
def __post_init__(self):
self.flaeche = self.breite * self.hoehe
Dann ergibt:
a = Rechteck(2, 3)
b = replace(a, breite=10)
print(a.flaeche)
print(b.flaeche)
korrekt:
6
30
flaeche wurde für b neu berechnet und nicht mit dem alten Wert kopiert.
Wann ist eine Dataclass sinnvoll?
Eine Dataclass passt gut, wenn die Struktur einer Klasse hauptsächlich aus klar benannten Fields besteht und ein großer Teil des Constructors nur aus Zuweisungen bestehen würde.
Typische Kandidaten sind:
- Konfigurationen
- Koordinaten
- Events
- Nachrichten
- Messergebnisse
- Value Objects
- kleine Domain Models
Ein gutes Signal ist diese Art von Klasse:
class Position:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Position(x={self.x!r}, y={self.y!r})"
Daraus wird:
@dataclass
class Position:
x: int
y: int
ohne dass Informationen verloren gehen.
Wann ist eine normale Klasse besser?
Eine Dataclass ist kein Ziel für jede Klasse.
Eine normale Klasse kann klarer sein, wenn der Constructor umfangreiche Abläufe steuert, der öffentliche State nicht sinnvoll als Fields beschrieben werden kann oder fast jede automatisch erzeugte Methode wieder deaktiviert wird.
Auch:
@dataclass(
init=False,
repr=False,
eq=False,
)
kann technisch sinnvoll sein.
Wenn Du aber anschließend Constructor, Repräsentation, Equality und fast jedes
Field-Verhalten selbst programmierst, lohnt sich die Frage, was @dataclass
überhaupt noch für Dich erledigt.
Die entscheidende Frage ist deshalb nicht:
Enthält die Klasse Methoden?
sondern:
Passen die automatisch aus ihren Fields erzeugbaren Methoden zum Modell?
Eine Dataclass kann problemlos reichlich Verhalten enthalten.
Stolperfallen
-
Typannotation mit Validierung verwechseln:
name: strverhindert keine falschen Typen zur Laufzeit. -
Annotation vergessen: Ein normales Klassenattribut ohne Annotation wird nicht zum Dataclass-Field.
-
ClassVarals normales Field erwarten:ClassVarwird vom Dataclass-Mechanismus ausdrücklich ignoriert. -
Pflichtfields hinter Default-Fields definieren: Das kann auch durch Vererbung entstehen und führt zu einem
TypeError. -
Mutable Defaults direkt angeben: Für Listen, Dictionaries, Sets und ähnliche Werte gehört normalerweise
default_factoryverwendet. -
Die Factory aufrufen: Es heißt
default_factory=list, nichtdefault_factory=list(). -
__str__erwarten: Dataclasses erzeugen__repr__, aber kein eigenes menschenlesbares__str__. -
Equality mit Identität verwechseln: Zwei unterschiedliche Instanzen derselben Dataclass können aufgrund identischer Field-Werte mit
==gleich sein. -
eq=Falseautomatisch mit Identitätsvergleich gleichsetzen: Dadurch wird lediglich keine neue__eq__erzeugt. Eine geerbte Implementierung kann weiterhin gelten. -
frozen=Truefür tiefe Immutability halten: Mutable Objekte innerhalb eines Fields können weiterhin verändert werden. -
frozen=Trueautomatisch mit problemlos hashable verwechseln: Ein generierter Hash kann immer noch an einem unhashable Field wie einer Liste scheitern. -
unsafe_hash=Trueleichtfertig verwenden: Mutiert ein hash-relevanter Wert, können Sets und Dictionaries anschließend inkonsistent arbeiten. -
order=Trueohne sinnvolle fachliche Reihenfolge einschalten: Verglichen wird anhand der Field-Reihenfolge. -
slots=Truenur als Performance-Schalter sehen: Bei wenigen Objekten ist die Speicherersparnis egal. Die eingeschränkte Attributmenge kann trotzdem nützlich sein. -
slots=Truebei eigener__slots__-Definition verwenden: Beides gleichzeitig ist nicht erlaubt. -
weakref_slot=Trueohneslots=Trueverwenden: Die Option setzt Slots voraus. -
InitVarfür gespeicherten State halten: EinInitVarwird an__post_init__übergeben, ist aber kein Instanz-Field. -
Zu viel Logik in
__post_init__verstecken: Wenn der Aufbau eines Objekts einen umfangreichen Workflow darstellt, kann ein eigener Constructor oder eine Factory Method klarer sein. -
replace()für eine rohe Objektkopie halten: Der Constructor und__post_init__laufen erneut;init=False-Fields werden nicht einfach vom alten Objekt übernommen. -
asdict()für einen flachen Export halten: Die Funktion arbeitet rekursiv und verwendet für andere Objektedeepcopy(). -
asdict()mit JSON-Serialisierung gleichsetzen: Nicht jeder Python-Typ im Ergebnis ist automatisch JSON-kompatibel.
Übungen
1. Einen einfachen Gegenstand modellieren
Schreibe eine Dataclass Gegenstand mit den Fields:
namebeschreibungmit dem Default""wertmit dem Default0
Zwei Gegenstände mit denselben Werten sollen automatisch als gleich gelten.
Lösung
from dataclasses import dataclass
@dataclass
class Gegenstand:
name: str
beschreibung: str = ""
wert: int = 0
fackel_1 = Gegenstand("Fackel", wert=10)
fackel_2 = Gegenstand("Fackel", wert=10)
print(fackel_1 == fackel_2)
True
2. Ein unabhängiges Inventar erzeugen
Schreibe eine Dataclass Spieler, bei der jedes Objekt ein eigenes leeres
Inventar erhält.
Lösung
from dataclasses import dataclass, field
@dataclass
class Spieler:
name: str
inventar: list[str] = field(default_factory=list)
karl = Spieler("Karl")
lara = Spieler("Lara")
karl.inventar.append("Fackel")
print(karl.inventar)
print(lara.inventar)
['Fackel']
[]
3. HP in __post_init__ berechnen
Ergänze den Spieler um max_hp und ein Field hp, das nicht als
Constructor-Parameter erscheint. hp soll nach der Initialisierung auf
max_hp gesetzt werden.
Lösung
from dataclasses import dataclass, field
@dataclass
class Spieler:
name: str
max_hp: int = 100
hp: int = field(init=False)
def __post_init__(self):
self.hp = self.max_hp
spieler = Spieler("Karl", max_hp=120)
print(spieler.hp)
120
4. Einen unveränderlichen Gegenstand erstellen
Mache Gegenstand unveränderlich und aktiviere Slots.
Lösung
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Gegenstand:
name: str
beschreibung: str = ""
fackel = Gegenstand("Fackel")
fackel.name = "Magische Fackel"
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.