dataclasses: Datenklassen in Python

1. Übersicht

Wenn Sie eine Klasse nur zum Speichern von Daten schreiben (wie Order, Customer, Point…), müssen Sie normalerweise selbst __init__, __repr__, __eq__ schreiben — alles ist wiederholter Code. Das dataclasses-Modul (seit Python 3.7 verfügbar) generiert diese Methoden automatisch mit nur einem Decorator. Nach diesem Artikel wissen Sie, wie Sie @dataclass verwenden, um Datenklassen prägnant zu schreiben, Daten mit __post_init__ zu validieren, Klassen unveränderlich zu machen und in dict/JSON umzuwandeln.

2. Was ist eine grundlegende Dataclass?

@dataclass ist ein Decorator, der Type-Hints in der Klasse liest und dann automatisch __init__, __repr__, __eq__ auf Basis dieser Felder generiert.

from dataclasses import dataclass

@dataclass
class Customer:
    name: str
    email: str
    age: int = 0
  • @dataclass scannt Attribute mit Type-Hints (name, email, age) und behandelt diese als Felder.
  • age: int = 0 hat einen Standardwert, ähnlich wie ein Standardparameter in einer Funktion.
  • Sie müssen nicht __init__ schreiben — es wird automatisch gemäß der Feldersortierung generiert.
c1 = Customer("An", "an@example.com")
print(c1)
Customer(name='An', email='an@example.com', age=0)

__repr__ wird automatisch generiert und zeigt den vollständigen Namen des Feldes und den Wert an — sehr praktisch beim Debuggen — normale Klassen haben das nicht standardmäßig.

3. Vergleich mit normalen Klassen und namedtuple

Eine manuell geschriebene äquivalente Klasse wäre viel länger:

class CustomerPlain:
    def __init__(self, name, email, age=0):
        self.name = name
        self.email = email
        self.age = age

    def __repr__(self):
        return f"CustomerPlain(name={self.name!r}, email={self.email!r}, age={self.age!r})"

    def __eq__(self, other):
        if not isinstance(other, CustomerPlain):
            return NotImplemented
        return (self.name, self.email, self.age) == (other.name, other.email, other.age)

@dataclass erspart Ihnen, all dieses zu schreiben. Im Vergleich zu namedtuple unterscheidet sich dataclass in folgenden Punkten:

  • Objekte sind standardmäßig veränderbar (Sie können c1.age = 20 ändern), namedtuple dagegen nicht.
  • Unterstützt explizite Type-Hints und integriert sich leicht mit Type-Checkern wie mypy.
  • Unterstützt __post_init__, komplexe Standardwerte, Vererbung — Dinge, die namedtuple nicht gut macht.

Hinweis: Wenn Sie nur ein leichtes unveränderliches Tuple ohne Methoden benötigen, ist namedtuple oder typing.NamedTuple immer noch eine knappere Wahl.

4. field() und veränderliche Standardwerte

Ein sehr häufiger Fehler: List/Dict direkt als Standardwert verwenden.

from dataclasses import dataclass, field

@dataclass
class Order:
    order_id: str
    items: list[str] = field(default_factory=list)
  • Wenn Sie items: list[str] = [] schreiben, erhalten Sie eine ValueError: mutable default ... is not allowed — weil der Standard-List zwischen allen Instanzen geteilt wird und zu schwer auffindbaren Bugs führt.
  • field(default_factory=list) fordert dataclass auf, list() neu für jede Instanz aufzurufen.
o1 = Order("A1")
o2 = Order("A2")
o1.items.append("Sách")

print(o1.items, o2.items)
['Sách'] []

Die beiden Objekte haben unabhängige items, wie erwartet.

5. Daten mit __post_init__ validieren

Der __init__, den dataclass generiert, ermöglicht keine direkte Einfügung von Validierungslogik — aber es gibt einen Hook __post_init__, der direkt nach Abschluss von __init__ läuft.

from dataclasses import dataclass

@dataclass
class PaymentRequest:
    amount: float
    currency: str = "VND"

    def __post_init__(self):
        if self.amount <= 0:
            raise ValueError("amount muss größer als 0 sein")
        self.currency = self.currency.upper()
  • __post_init__ wird automatisch aufgerufen, nachdem alle Felder zugewiesen sind.
  • Hier validieren wir amount und normalisieren currency in Großbuchstaben.
try:
    PaymentRequest(amount=-100)
except ValueError as e:
    print(e)
amount muss größer als 0 sein

6. Unveränderliche Klasse mit frozen=True

Wenn Sie möchten, dass das Objekt nach der Erstellung nicht geändert werden kann (ähnlich wie ein Record in Java), verwenden Sie frozen=True.

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float
  • frozen=True blockiert __setattr__ — jede Zuweisung nach der Initialisierung wirft einen Fehler.
  • Dank der Unveränderlichkeit können frozen-Objekte als Schlüssel in einem dict oder Element in einem set verwendet werden (da dataclass automatisch __hash__ generiert, wenn frozen=True und eq=True).
p = Point(1.0, 2.0)
try:
    p.x = 5.0
except Exception as e:
    print(type(e).__name__, e)
FrozenInstanceError cannot assign to field 'x'

Hinweis: frozen=True blockiert nur die Zuweisung auf oberster Ebene des Attributs. Wenn ein Feld vom Typ List/Dict ist, kann der Inhalt darin immer noch geändert werden. Für echte Unveränderlichkeit verwenden Sie tuple oder einen unveränderlichen Typ für dieses Feld.

7. Dataclass-Vererbung

Dataclass unterstützt normale Python-Vererbung, Felder der abgeleiteten Klasse werden nach den Feldern der Basisklasse hinzugefügt.

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

@dataclass
class Employee(Person):
    salary: float = 0.0
  • Employee erbt name, age von Person und fügt salary hinzu.
  • Die Reihenfolge der Parameter in dem generierten __init__ ist: name, age, salary — die Deklarationsreihenfolge wird beibehalten.
e = Employee("Bình", 30, salary=15_000_000)
print(e)
Employee(name='Bình', age=30, salary=15000000.0)

Hinweis: Felder ohne Standardwert müssen vor Feldern mit Standardwert in der gesamten Vererbungskette stehen. Wenn die Basisklasse ein Feld mit Standardwert hat und die abgeleitete Klasse ein Feld ohne Standardwert hinzufügt, wirft Python beim Definieren der Klasse einen TypeError.

8. Vergleich und Sortierung mit order=True

Standardmäßig generiert dataclass nur __eq__. Um <, >, <= zu vergleichen, verwenden Sie order=True.

from dataclasses import dataclass, field

@dataclass(order=True)
class Product:
    price: float
    name: str = field(compare=False)
  • order=True generiert zusätzlich __lt__, __le__, __gt__, __ge__, die Felder werden in der Reihenfolge verglichen (ähnlich wie Tuple-Vergleich).
  • field(compare=False) schließt name aus dem Vergleich aus — nur nach price wird verglichen.
products = [Product(50_000, "Bút"), Product(20_000, "Vở")]
print(sorted(products))
[Product(price=20000, name='Vở'), Product(price=50000, name='Bút')]

sorted() verwendet __lt__, das von dataclass generiert wird, Sie brauchen nicht manuell key=lambda p: p.price zu schreiben.

9. Konvertierung zu dict und tuple

dataclasses bietet praktische Funktionen asdict und astuple zum Serialisieren von Objekten.

from dataclasses import dataclass, asdict, astuple

@dataclass
class Address:
    city: str
    country: str

@dataclass
class Customer2:
    name: str
    address: Address
cust = Customer2("Chi", Address("Hà Nội", "Việt Nam"))
print(asdict(cust))
print(astuple(cust))
{'name': 'Chi', 'address': {'city': 'Hà Nội', 'country': 'Việt Nam'}}
('Chi', ('Hà Nội', 'Việt Nam'))
  • asdict konvertiert rekursiv auch verschachtelte dataclass (Address) in ein untergeordnetes dict — sehr praktisch für die JSON-Rückgabe über die API.
  • astuple ist ähnlich, gibt aber verschachtelte Tupel zurück.

Hinweis: asdict/astuple erstellen eine vollständige Tiefenkopie der Daten. Bei großen Objekten oder solchen mit nicht kopierbaren Feldern (Datei-Handle, Verbindung…) ist der Aufruf dieser Funktionen langsam oder fehlerhaft.

10. Schreiben von Tests für dataclass

Da __eq__ automatisch basierend auf Feldwerten generiert wird, ist das Vergleichen von Objekten mit pytest sehr natürlich.

import pytest
from dataclasses import FrozenInstanceError

def test_customer_equality():
    c1 = Customer("An", "an@example.com", 20)
    c2 = Customer("An", "an@example.com", 20)
    assert c1 == c2

def test_payment_request_rejects_non_positive_amount():
    with pytest.raises(ValueError):
        PaymentRequest(amount=0)

def test_point_is_frozen():
    p = Point(1.0, 2.0)
    with pytest.raises(FrozenInstanceError):
        p.x = 10.0
  • test_customer_equality prüft, dass zwei verschiedene Instanzen mit denselben Feldwerten sich == sind — stimmt dank automatisch generiertem __eq__.
  • test_payment_request_rejects_non_positive_amount prüft, dass __post_init__ korrekt validiert.
  • test_point_is_frozen bestätigt, dass frozen=True Attributzuweisungen blockiert.

11. Häufige Fehler und wann Sie dataclass nicht verwenden sollten

  • default_factory für veränderliche Standardwerte vergessen: Wenn Sie = [] oder = {} direkt verwenden, erhalten Sie sofort einen Fehler beim Deklarieren der Klasse, Sie müssen field(default_factory=...) verwenden.
  • Felder mit und ohne Standardwert in falscher Reihenfolge bei Vererbung: Ursacht TypeError beim Laden des Moduls, schwer zu debuggen, wenn Klassen in mehreren Dateien verteilt sind.
  • Annahme, dass frozen=True tiefe Unveränderlichkeit ist: Felder vom Typ List/Dict können ihren Inhalt immer noch ändern, nur die Attributzuweisung ist blockiert.
  • Dataclass für komplexe Geschäftslogik verwenden: Wenn eine Klasse viele Methoden zur Verarbeitung und komplexe Beziehungen zu anderen Objekten hat, hilft dataclass nur bei Initialisierung/Vergleich — Sie müssen die Logik immer noch selbst schreiben, machen Sie nicht vor, dass die Klasse einfacher ist als sie tatsächlich ist.
  • Kompatibilität mit Python < 3.7 benötigen: dataclasses ist nicht in diesen Versionen enthalten, Sie müssen das dataclasses-Paket von PyPI installieren (Backport) oder attrs verwenden.

Hinweis: Wenn Sie komplexe Validierung, benutzerdefinierte Serialisierung oder tiefe JSON-Schema-Integration benötigen, sind Bibliotheken wie pydantic oft stärker als reines dataclasses — es lohnt sich, diese zu berücksichtigen, wenn Ihr Projekt größer wird.

12. Fazit

dataclasses hilft Ihnen, den größten Teil des Boilerplate-Codes bei der Schreibweise von Datenklassen zu eliminieren: generiert automatisch __init__, __repr__, __eq__, unterstützt Validierung über __post_init__, Unveränderlichkeit über frozen=True und einfache Serialisierung über asdict/astuple. Für einfache bis mittlere Datenklassen ist dies eine prägnantere und lesbarere Wahl als manuelles Schreiben. Wenn die Anforderungen an Validierung oder Serialisierung komplexer werden, können Sie in Betracht ziehen, pydantic oder attrs als Alternative zu verwenden.

pythondataclassesoopclean-code