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

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:

  • name
  • beschreibung

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:

  1. zuerst punkte,
  2. 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 repr hilfreich 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: str verhindert 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, nicht default_factory=list().

  • __str__ erwarten: @dataclass erzeugt __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=True als 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=True leichtfertig verwenden: Ein veränderter Hash-relevanter State kann Sets und Dictionaries beschädigen.

  • order=True ohne fachliche Reihenfolge aktivieren: Dataclasses vergleichen Fields in ihrer definierten Reihenfolge.

  • slots=True nur 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:

  • 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 ein neues Objekt mit `replace(...)` erzeugen.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!