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
Zusätzlich haben wir Methoden wie __repr__ geschrieben, obwohl sie lediglich
alle Attribute auflisten:
def __repr__(self):
return (
f"Gegenstand("
f"name={self.name!r}, "
f"beschreibung={self.beschreibung!r}"
f")"
)
Diese wiederkehrende Schreibarbeit wird als Boilerplate bezeichnet: Code, der notwendig ist, aber kaum eigene Programmlogik enthält.
Für Klassen, die vor allem strukturierten State speichern, bietet Python deshalb
das Standardmodul dataclasses.
Die erste Dataclass
Eine Dataclass wird mit dem Decorator @dataclass markiert:
from dataclasses import dataclass
@dataclass
class Gegenstand:
name: str
beschreibung: str = ""
Mehr benötigen wir für die grundlegende Klasse nicht.
Python erzeugt unter anderem eine passende __init__-Methode. Dadurch können
wir die Klasse wie gewohnt verwenden:
fackel = Gegenstand(
name="Fackel",
beschreibung="Eine rußige, aber noch brauchbare Fackel.",
)
print(fackel.name)
print(fackel.beschreibung)
Die Ausgabe lautet:
Fackel
Eine rußige, aber noch brauchbare Fackel.
Konzeptionell erzeugt @dataclass ungefähr diese Initialisierung:
def __init__(self, name: str, beschreibung: str = ""):
self.name = name
self.beschreibung = beschreibung
Die tatsächliche Implementierung übernimmt Python für uns.
Was ist ein Decorator?
Die Schreibweise
@dataclass
class Gegenstand:
...
wendet eine Funktion auf die Klasse an.
Vereinfacht entspricht sie:
class Gegenstand:
...
Gegenstand = dataclass(Gegenstand)
Der Decorator untersucht die Klassendefinition und ergänzt automatisch bestimmte Dunder Methods.
Welche Methoden erzeugt werden, lässt sich über Optionen steuern. Ohne weitere
Angaben verwendet @dataclass unter anderem diese Defaults:
@dataclass(
init=True,
repr=True,
eq=True,
order=False,
frozen=False,
slots=False,
)
class Gegenstand:
...
Für die meisten einfachen Dataclasses genügt die kurze Schreibweise:
@dataclass
class Gegenstand:
...
Fields statt handgeschriebenem __init__
Die annotierten Attribute einer Dataclass werden als Fields bezeichnet:
@dataclass
class Gegenstand:
name: str
beschreibung: str = ""
Die Klasse besitzt zwei Fields:
namebeschreibung
name hat keinen Default-Wert und muss deshalb beim Erzeugen eines Objekts
angegeben werden:
fackel = Gegenstand("Fackel")
beschreibung besitzt den Default-Wert "" und ist optional:
fackel = Gegenstand("Fackel")
schluessel = Gegenstand(
"Schlüssel",
"Ein schwerer Schlüssel mit rostigen Zähnen.",
)
Die Reihenfolge der Fields bestimmt zugleich die Reihenfolge der positionalen
Parameter im generierten __init__.
Fields benötigen eine Typannotation
@dataclass erkennt Fields grundsätzlich an ihren Typannotationen:
@dataclass
class Gegenstand:
name: str
wert: int
Eine Zuweisung ohne Annotation wird nicht als Dataclass Field behandelt:
@dataclass
class Gegenstand:
kategorie = "Allgemein"
name: str = "Unbekannt"
kategorie ist hier ein normales Klassenattribut und kein Parameter des
generierten __init__.
Dieser Aufruf funktioniert deshalb nicht:
Gegenstand(kategorie="Werkzeug")
Python meldet, dass kategorie kein erwartetes Argument ist.
Soll ein annotiertes Attribut ausdrücklich als Klassenvariable behandelt werden,
verwendest Du ClassVar:
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 wird von dataclass nicht als
Field behandelt.
Typannotationen sind keine Laufzeitvalidierung
Die Typannotationen beschreiben, welche Werte erwartet werden:
@dataclass
class Gegenstand:
name: str
wert: int = 0
Sie verhindern zur Laufzeit aber nicht automatisch falsche Typen:
gegenstand = Gegenstand(
name=123,
wert="teuer",
)
Python erzeugt das Objekt zunächst trotzdem.
Die Annotationen helfen:
- dem Leser des Codes,
- dem Editor,
- Autocompletion,
- statischen Type Checkern,
- Tools wie Ruff.
Eine Dataclass ist jedoch kein automatisches Validierungs- oder Konvertierungssystem. Benötigst Du Laufzeitprüfungen, musst Du sie selbst implementieren oder ein dafür vorgesehenes Tool verwenden.
__repr__ wird automatisch erzeugt
Eine Dataclass erhält standardmäßig eine hilfreiche __repr__-Methode:
fackel = Gegenstand(
name="Fackel",
beschreibung="Eine rußige Fackel.",
)
print(repr(fackel))
Ausgabe:
Gegenstand(name='Fackel', beschreibung='Eine rußige Fackel.')
Auch eine einfache Ausgabe mit print(...) zeigt diese Darstellung, solange wir
kein eigenes __str__ definiert haben:
print(fackel)
Ausgabe:
Gegenstand(name='Fackel', beschreibung='Eine rußige Fackel.')
@dataclass erzeugt dabei __repr__, aber nicht automatisch ein eigenes
__str__.
Für eine kurze, menschenlesbare Ausgabe können wir weiterhin selbst __str__
implementieren:
@dataclass
class Gegenstand:
name: str
beschreibung: str = ""
def __str__(self):
return self.name
Nun ergibt:
print(Gegenstand("Fackel"))
die Ausgabe:
Fackel
Die automatisch erzeugte __repr__-Methode bleibt trotzdem erhalten:
print(repr(Gegenstand("Fackel")))
Ausgabe:
Gegenstand(name='Fackel', beschreibung='')
Equality nach Field-Werten
Dataclasses erzeugen standardmäßig auch eine __eq__-Methode.
Zwei normale Objekte ohne eigene Equality gelten nur dann als gleich, wenn beide Namen auf dasselbe Objekt verweisen. Dataclasses vergleichen dagegen 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
Obwohl fackel_1 und fackel_2 zwei verschiedene Objekte sind, besitzen sie
dieselben Field-Werte.
Der Vergleich berücksichtigt die Fields in ihrer definierten Reihenfolge. Beide Objekte müssen außerdem Instanzen derselben konkreten Klasse sein.
Du kannst die automatische Equality deaktivieren:
@dataclass(eq=False)
class Spieler:
name: str
Dann verwendet die Klasse wieder das normale identitätsbasierte Verhalten, sofern
keine eigene __eq__-Methode definiert wird.
Das kann bei Spielobjekten sinnvoll sein: Zwei Spieler mit demselben Namen und demselben State müssen nicht zwangsläufig dieselbe Spielfigur darstellen.
Fields ohne und mit Default-Wert
Pflichtfelder müssen vor Fields mit Default-Wert stehen:
@dataclass
class Gegenstand:
name: str
wert: int = 0
Diese Reihenfolge ist gültig.
Umgekehrt funktioniert es nicht:
@dataclass
class Gegenstand:
wert: int = 0
name: str
Python kann kein gültiges __init__ mit einem Pflichtparameter nach einem
optionalen Parameter erzeugen und meldet deshalb einen TypeError.
Die Regel gilt auch über Vererbung hinweg.
Mutable Defaults und default_factory
Für das Inventar eines Spielers benötigen wir eine Liste:
@dataclass
class Spieler:
name: str
inventar: list = []
Diese Definition ist problematisch. Wie bei Default-Werten normaler Funktionen würde dieselbe Liste von mehreren Objekten geteilt.
Aktuelle Python-Versionen erkennen viele solcher Fälle und lehnen den
unhashable Default bereits beim Erstellen der Klasse mit einem ValueError ab.
Die richtige Lösung ist field(default_factory=...):
from dataclasses import dataclass, field
@dataclass
class Spieler:
name: str
inventar: list[str] = field(default_factory=list)
default_factory=list bedeutet:
Wenn kein Inventar übergeben wurde, rufe
list()auf und verwende das Ergebnis.
Bei jedem neuen Spieler entsteht dadurch eine neue Liste:
karl = Spieler("Karl")
lara = Spieler("Lara")
karl.inventar.append("Fackel")
print(karl.inventar)
print(lara.inventar)
Ausgabe:
['Fackel']
[]
Die beiden Objekte teilen sich ihr Inventar nicht.
Beliebige Default Factories
Eine Default Factory kann jede Funktion sein, die sich ohne Argumente aufrufen lässt:
@dataclass
class Spieler:
name: str
inventar: list[str] = field(
default_factory=lambda: ["Brot"]
)
Jeder neue Spieler beginnt nun mit einem eigenen Brot:
karl = Spieler("Karl")
lara = Spieler("Lara")
print(karl.inventar)
print(lara.inventar)
Ausgabe:
['Brot']
['Brot']
Auch Dictionaries und Sets werden so angelegt:
@dataclass
class Raum:
ausgaenge: dict[str, str] = field(default_factory=dict)
besucher: set[str] = field(default_factory=set)
Der Wert von default_factory ist die Funktion selbst:
default_factory=list
Nicht ihr bereits erzeugtes Ergebnis:
# Falsch:
default_factory=list()
field(...) genauer steuern
Mit field(...) kannst Du einzelne Fields anpassen.
Die wichtigsten Optionen sind:
| Option | Bedeutung |
|---|---|
default |
fester Default-Wert |
default_factory |
Funktion zum Erzeugen eines Default-Werts |
init |
Field im generierten __init__ anbieten |
repr |
Field im generierten __repr__ anzeigen |
compare |
Field bei Equality und Ordering berücksichtigen |
kw_only |
Field nur als Keyword Argument erlauben |
Ein Beispiel:
@dataclass
class Spieler:
name: str
hp: int = field(init=False)
passwort: str = field(repr=False, compare=False)
inventar: list[str] = field(
default_factory=list,
repr=False,
)
Das Field hp ist nicht Teil des generierten Constructors:
spieler = Spieler(
name="Karl",
passwort="geheim",
)
Dieser Aufruf wäre ungültig:
Spieler(
name="Karl",
hp=50,
passwort="geheim",
)
passwort und inventar erscheinen nicht in repr(...):
print(repr(spieler))
Ausgabe:
Spieler(name='Karl', hp=...)
Der genaue HP-Wert muss allerdings noch initialisiert werden. Dafür eignet sich
__post_init__.
__post_init__: nach der Initialisierung nacharbeiten
Schreibt @dataclass das __init__ automatisch, können wir darin keine eigene
Initialisierungslogik ergänzen.
Dafür gibt es __post_init__:
@dataclass
class Spieler:
name: str
max_hp: int = 100
hp: int = field(init=False)
def __post_init__(self):
self.hp = self.max_hp
Der erzeugte Ablauf entspricht vereinfacht:
def __init__(self, name, max_hp=100):
self.name = name
self.max_hp = max_hp
self.__post_init__()
Dadurch beginnt jeder Spieler mit vollen Lebenspunkten:
spieler = Spieler("Karl", max_hp=120)
print(spieler.hp)
Ausgabe:
120
__post_init__ eignet sich unter anderem für:
- berechnete Fields,
- Normalisierung,
- einfache Validierung,
- abhängige Default-Werte,
- das Initialisieren einer normalen Basisklasse.
Werte normalisieren und validieren
Wir können den Start-State direkt überprüfen:
@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
Damit sorgt die Klasse selbst dafür, dass sie nicht mit einem ungültigen Start-State verwendet wird.
Die Typen werden dadurch weiterhin nicht automatisch validiert. Die Prüfungen beziehen sich nur auf die Regeln, die wir ausdrücklich implementieren.
InitVar: Input nur für die Initialisierung
Manchmal benötigt __post_init__ einen Wert, der kein dauerhaftes Attribut des
Objekts 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 __init__:
spieler = Spieler(
name="Karl",
max_hp=100,
start_hp=50,
)
Es wird an __post_init__ übergeben, aber nicht als normales Field am Objekt
gespeichert:
print(spieler.hp)
Ausgabe:
50
Dieser Zugriff funktioniert dagegen nicht:
print(spieler.start_hp)
InitVar ist für Werte gedacht, die nur beim Aufbau des Objekts benötigt
werden. Für die meisten einfachen Dataclasses reicht ein normales Field oder
init=False.
Keyword-only Dataclasses
Bei vielen ähnlich typisierten Fields können positionale Argumente schwer verständlich werden:
raum = Raum(
"Halle",
"Eine dunkle Halle.",
{"norden": "bibliothek"},
[],
)
Welche Collection Ausgänge und welche Gegenstände enthält, erkennt man nur aus der Klassendefinition.
Seit Python 3.10 kann eine Dataclass alle Constructor-Parameter als Keyword-only markieren:
@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 muss der Aufruf die Namen angeben:
raum = Raum(
name="Halle",
beschreibung="Eine dunkle Halle.",
ausgaenge={"norden": "bibliothek"},
gegenstaende=[Gegenstand("Fackel")],
)
Dieser Aufruf ist ausführlicher, aber deutlich leichter zu lesen.
Die positionale Variante ist nun nicht mehr erlaubt:
Raum("Halle", "Eine dunkle Halle.")
Du kannst auch nur einzelne Fields als Keyword-only definieren:
@dataclass
class Gegenstand:
name: str
beschreibung: str = field(default="", kw_only=True)
Dann funktioniert:
Gegenstand(
"Fackel",
beschreibung="Eine rußige Fackel.",
)
frozen=True: Zuweisungen verhindern
Für einen einfachen Wert wie einen Gegenstand kann es sinnvoll sein, Änderungen nach der Erstellung zu verhindern:
@dataclass(frozen=True)
class Gegenstand:
name: str
beschreibung: str = ""
Dieser Code funktioniert:
fackel = Gegenstand("Fackel")
Eine spätere Zuweisung führt dagegen zu einem FrozenInstanceError:
fackel.name = "Magische Fackel"
Das Objekt verhält sich damit weitgehend unveränderbar.
frozen ist nur flach
frozen=True verhindert, dass ein Field auf einen anderen Wert gesetzt wird. Es
macht enthaltene mutable Objekte aber nicht automatisch unveränderbar:
@dataclass(frozen=True)
class Gruppe:
mitglieder: list[str] = field(default_factory=list)
Diese Zuweisung ist verboten:
gruppe.mitglieder = ["Karl"]
Der Inhalt der vorhandenen Liste kann trotzdem verändert werden:
gruppe.mitglieder.append("Karl")
Für ein tatsächlich unveränderliches Field solltest Du auch einen immutable Datentyp verwenden:
@dataclass(frozen=True)
class Gruppe:
mitglieder: tuple[str, ...] = ()
Dataclasses als Dictionary-Schlüssel
Dictionary-Schlüssel und Set-Elemente müssen hashable sein.
Eine mutable Dataclass mit aktivierter Equality ist standardmäßig nicht hashable:
@dataclass
class Gegenstand:
name: str
Dieser Code führt zu einem TypeError:
gegenstaende = {Gegenstand("Fackel")}
Bei frozen=True erzeugt dataclass normalerweise auch eine passende
__hash__-Methode:
@dataclass(frozen=True)
class Gegenstand:
name: str
beschreibung: str = ""
Nun kann der Gegenstand in einem Set liegen:
gegenstaende = {
Gegenstand("Fackel"),
Gegenstand("Schlüssel"),
}
Alle beteiligten Fields müssen dafür selbst hashable sein. Eine eingefrorene Dataclass mit einem Listen-Field bleibt deshalb problematisch.
Mit unsafe_hash=True lässt sich die Erzeugung einer Hash-Methode erzwingen:
@dataclass(unsafe_hash=True)
class Spieler:
name: str
Der Name der Option ist bewusst warnend. Verändert sich ein Wert, der Teil des Hashes ist, während das Objekt in einem Set oder als Dictionary-Schlüssel verwendet wird, kann die Collection inkonsistent werden.
unsafe_hash=True solltest Du deshalb nur in gut begründeten Spezialfällen
verwenden.
Sortierung mit order=True
Standardmäßig erzeugt @dataclass Equality, aber keine kleineren- oder
größeren-Vergleiche.
Mit order=True werden zusätzlich __lt__, __le__, __gt__ und __ge__
erzeugt:
@dataclass(order=True)
class RanglistenEintrag:
punkte: int
name: str
Nun können die Objekte sortiert werden:
rangliste = [
RanglistenEintrag(120, "Karl"),
RanglistenEintrag(90, "Lara"),
RanglistenEintrag(120, "Anna"),
]
print(sorted(rangliste))
Die Fields werden in ihrer definierten Reihenfolge verglichen:
- zuerst
punkte, - bei Gleichstand
name.
Das Ergebnis ist aufsteigend:
[
RanglistenEintrag(punkte=90, name='Lara'),
RanglistenEintrag(punkte=120, name='Anna'),
RanglistenEintrag(punkte=120, name='Karl')
]
Soll ein Field nicht am Vergleich teilnehmen, setzt Du compare=False:
@dataclass(order=True)
class RanglistenEintrag:
punkte: int
name: str = field(compare=False)
Jetzt entscheidet ausschließlich punkte über die Reihenfolge.
order=True ist nur sinnvoll, wenn die Reihenfolge der Fields tatsächlich eine
fachlich sinnvolle Sortierung ergibt.
slots=True
Normale Python-Objekte speichern Attribute üblicherweise in einem internen Dictionary. Das erlaubt, nachträglich neue Attribute anzulegen:
gegenstand.neues_attribut = 123
Mit slots=True erzeugt die Dataclass passende __slots__:
@dataclass(slots=True)
class Gegenstand:
name: str
beschreibung: str = ""
Bei einer einfachen Klasse bringt das zwei Vorteile:
- Die Objekte benötigen meist weniger Speicher.
- Versehentlich neue Attribute werden verhindert.
Dieser Tippfehler führt nun zu einem AttributeError:
gegenstand.beschreibunng = "Tippfehler"
Statt unbemerkt ein neues Attribut anzulegen.
Der Speicherunterschied spielt vor allem bei sehr vielen Objekten eine Rolle. Für drei Räume und zwei Gegenstände wäre er praktisch bedeutungslos. Die eingeschränkte Attributmenge kann trotzdem hilfreich sein.
Bei Vererbung und bestimmten Metaprogramming-Techniken bringt slots=True
zusätzliche Besonderheiten mit sich. Für einfache Dataclasses ist die Option
meist unkompliziert.
Mehrere Optionen kombinieren
Optionen lassen sich gemeinsam verwenden:
@dataclass(
frozen=True,
slots=True,
)
class Gegenstand:
name: str
beschreibung: str = ""
Diese Klasse:
- erzeugt
__init__,__repr__und__eq__, - verhindert spätere Field-Zuweisungen,
- verwendet Slots,
- ist bei hashable Fields selbst hashable.
Für einen Raum verwenden wir andere Optionen:
@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, wird aber ausschließlich mit Keyword Arguments erzeugt.
Die passenden Optionen hängen von der fachlichen Bedeutung der Klasse ab. Es gibt keine Kombination, die für jede Dataclass automatisch richtig ist.
Dataclasses dürfen eigene Methoden besitzen
Eine Dataclass ist weiterhin eine normale Python-Klasse. Sie darf beliebige Methoden enthalten:
@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 deshalb nicht ausschließlich für passive Datencontainer geeignet.
Der Decorator nimmt uns nur bestimmte mechanische Methoden ab. Die eigentliche Programmlogik können und sollen wir weiterhin selbst implementieren.
Der Dungeon mit Dataclasses
Wir wandeln die Klassen aus Teil 7 nun in Dataclasses um.
Gegenstand wird ein immutable Value Object. Spieler und Raum bleiben
veränderbar, weil sich ihre Lebenspunkte, Inventare und Gegenstände während des
Spiels ä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. Die Klassendefinitionen sind jedoch deutlich kürzer.
Was @dataclass im Dungeon übernimmt
Bei Gegenstand schreiben wir nicht mehr selbst:
def __init__(self, name, beschreibung=""):
self.name = name
self.beschreibung = beschreibung
Auch __repr__ und __eq__ entstehen automatisch.
Unsere Klasse enthält nur noch die Field-Definitionen und das tatsächlich fachliche Verhalten:
@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 zwei Spieler mit demselben State nicht
automatisch als dieselbe Spielfigur behandelt werden sollen.
Raum verwendet kw_only=True, weil der Constructor mehrere ähnliche
Argumente enthält und benannte Argumente deutlich lesbarer sind.
Dataclasses und Pattern Matching
Dataclasses lassen sich auch mit Structural Pattern Matching verwenden:
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__ für nicht
Keyword-only Fields. Dadurch sind auch positionale Class Patterns möglich:
match gegenstand:
case Gegenstand("Fackel", _):
print("Du hast eine Fackel gefunden.")
Keyword Patterns sind häufig deutlicher, weil sie nicht von der Reihenfolge der Fields abhängen.
Mehr dazu findest Du im Artikel match/case: Strukturelles Pattern Matching.
Dataclasses in Dictionaries umwandeln
Das Modul stellt mit asdict(...) eine Funktion bereit, die eine Dataclass in
ein Dictionary umwandelt:
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.'
}
Verschachtelte Dataclasses und enthaltene Listen, Tupel und Dictionaries werden dabei rekursiv verarbeitet.
Das kann als Vorbereitung für Logs, Debug-Ausgaben oder eine spätere
Serialisierung nützlich sein. Es bedeutet aber nicht, dass jedes Ergebnis
automatisch gültiges JSON ist. Andere Datentypen wie datetime, set oder
Enums benötigen gegebenenfalls eine eigene Umwandlung.
asdict(...) erzeugt außerdem nicht nur einen flachen Blick auf die vorhandenen
Attribute, sondern kopiert die enthaltenen Werte rekursiv.
Veränderte Kopien mit replace(...)
Besonders bei frozen Dataclasses können wir Fields nicht direkt ändern:
fackel.name = "Magische Fackel"
Mit replace(...) erzeugen wir stattdessen ein neues Objekt:
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 unverändert:
print(fackel)
print(magische_fackel)
Ausgabe:
Fackel
Magische Fackel
replace(...) ruft den Constructor der Dataclass erneut auf. Dadurch wird auch
__post_init__ ausgeführt.
Bei Fields mit init=False gelten besondere Einschränkungen. Für einfache Value
Objects wie unseren Gegenstand ist replace(...) unkompliziert.
Wann ist eine Dataclass sinnvoll?
Eine Dataclass eignet sich besonders, wenn:
- die Klasse mehrere klar benannte Fields besitzt,
- die Initialisierung überwiegend aus Zuweisungen besteht,
- eine automatische
reprhilfreich ist, - Objekte nach ihren Field-Werten verglichen werden sollen,
- die Klasse strukturierten State repräsentiert,
- nur wenig spezielle Constructor-Logik notwendig ist.
Typische Beispiele sind:
- Konfigurationen,
- Koordinaten,
- Events,
- Nachrichten,
- Messergebnisse,
- Value Objects,
- einfache Domain Models.
Eine Dataclass darf trotzdem umfangreiche Methoden besitzen. Entscheidend ist nicht, ob die Klasse ausschließlich Daten enthält, sondern ob die automatisch erzeugten Methoden zu ihrem Modell passen.
Wann ist eine normale Klasse besser?
Eine normale Klasse kann passender sein, wenn:
- der Aufbau des Objekts stark vom normalen Field-Schema abweicht,
- viele interne Details nicht als Fields erscheinen sollen,
- Equality nach Fields keinen Sinn ergibt,
- der Constructor umfangreiche Abläufe steuert,
- Attribute überwiegend dynamisch entstehen,
- die öffentliche API hauptsächlich aus Verhalten besteht.
Auch dann kann eine Dataclass mit init=False, eq=False oder angepassten Fields
funktionieren. Wenn jedoch fast jede automatische Funktion deaktiviert oder
überschrieben wird, bringt der Decorator nur noch wenig Nutzen.
Dataclasses sind ein Werkzeug, kein Ziel für jede Klasse.
Stolperfallen
-
Eine Typannotation mit Validierung verwechseln:
name: strverhindert nicht automatisch, dass zur Laufzeit eine Zahl übergeben wird. -
Die Annotation vergessen: Ein nicht annotiertes Klassenattribut wird nicht als Dataclass Field erkannt.
-
Pflichtfelder nach Default-Feldern definieren: Fields ohne Default müssen vor Fields mit Default stehen.
-
Mutable Defaults direkt angeben: Verwende für Listen, Dictionaries und Sets
field(default_factory=...). -
Die Factory aufrufen: Es heißt
default_factory=list, nichtdefault_factory=list(). -
__str__erwarten:@dataclasserzeugt__repr__, aber kein eigenes menschenlesbares__str__. -
Equality mit Identität verwechseln: Zwei verschiedene Dataclass-Objekte können aufgrund gleicher Field-Werte mit
==als gleich gelten. -
frozen=Trueals tiefe Unveränderbarkeit verstehen: Enthaltene Listen oder Dictionaries lassen sich weiterhin verändern. -
Frozen automatisch für hashable halten: Auch die beteiligten Fields müssen hashable sein.
-
unsafe_hash=Trueleichtfertig verwenden: Ein veränderter Hash-relevanter State kann Sets und Dictionaries beschädigen. -
order=Trueohne fachliche Reihenfolge aktivieren: Dataclasses vergleichen Fields in ihrer definierten Reihenfolge. -
slots=Truenur wegen Performance verwenden: Bei wenigen Objekten ist die Ersparnis meist irrelevant. Die eingeschränkten Attribute können trotzdem nützlich sein. -
Zu viel in
__post_init__verstecken: Umfangreiche Initialisierungsabläufe können mit einer normalen Klasse oder einer alternativen Constructor-Methode klarer werden. -
asdict(...)als fertige JSON-Serialisierung ansehen: Nicht jeder enthaltene Python-Typ 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"
Weiterlesen
- Python lernen, Teil 7: Klassen und Objekte
- match/case: Strukturelles Pattern Matching
- Das Python-Datenmodell: Dunder Methodsgeplant
- Python-Dokumentation:
dataclasses - PEP 557: Data Classes
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.