Zum Inhalt springen

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:

  1. zuerst punkte,
  2. 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: str verhindert keine falschen Typen zur Laufzeit.

  • Annotation vergessen: Ein normales Klassenattribut ohne Annotation wird nicht zum Dataclass-Field.

  • ClassVar als normales Field erwarten: ClassVar wird 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_factory verwendet.

  • Die Factory aufrufen: Es heißt default_factory=list, nicht default_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=False automatisch mit Identitätsvergleich gleichsetzen: Dadurch wird lediglich keine neue __eq__ erzeugt. Eine geerbte Implementierung kann weiterhin gelten.

  • frozen=True für tiefe Immutability halten: Mutable Objekte innerhalb eines Fields können weiterhin verändert werden.

  • frozen=True automatisch mit problemlos hashable verwechseln: Ein generierter Hash kann immer noch an einem unhashable Field wie einer Liste scheitern.

  • unsafe_hash=True leichtfertig verwenden: Mutiert ein hash-relevanter Wert, können Sets und Dictionaries anschließend inkonsistent arbeiten.

  • order=True ohne sinnvolle fachliche Reihenfolge einschalten: Verglichen wird anhand der Field-Reihenfolge.

  • slots=True nur als Performance-Schalter sehen: Bei wenigen Objekten ist die Speicherersparnis egal. Die eingeschränkte Attributmenge kann trotzdem nützlich sein.

  • slots=True bei eigener __slots__-Definition verwenden: Beides gleichzeitig ist nicht erlaubt.

  • weakref_slot=True ohne slots=True verwenden: Die Option setzt Slots voraus.

  • InitVar für gespeicherten State halten: Ein InitVar wird 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 Objekte deepcopy().

  • 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:

  • name
  • beschreibung mit dem Default ""
  • wert mit dem Default 0

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
Test:
fackel_1 = Gegenstand("Fackel", wert=10)
fackel_2 = Gegenstand("Fackel", wert=10)

print(fackel_1 == fackel_2)
Ausgabe:
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)
Test:
karl = Spieler("Karl")
lara = Spieler("Lara")

karl.inventar.append("Fackel")

print(karl.inventar)
print(lara.inventar)
Ausgabe:
['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
Test:
spieler = Spieler("Karl", max_hp=120)

print(spieler.hp)
Ausgabe:
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 = ""
Diese Zuweisung ist nun nicht erlaubt:
fackel = Gegenstand("Fackel")
fackel.name = "Magische Fackel"
Für eine veränderte Variante kannst Du stattdessen `replace()` verwenden.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!