dataclasses: Python中的精简数据类

1. 概述

当编写仅用于存储数据的类(如Order、Customer、Point等)时,你通常必须自己编写__init__、__repr__、__eq__——这些都是重复代码。dataclasses模块(自Python 3.7起可用)只需通过一个装饰器就能帮你自动生成这些方法。读完这篇文章后,你将学会如何使用@dataclass编写精简的数据类、通过__post_init__验证数据、创建不可变的类,以及转换为dict/JSON。

2. 什么是基础dataclass

@dataclass是一个装饰器,它读取类中的类型提示,然后根据这些字段自动生成__init__、__repr__、__eq__。

from dataclasses import dataclass

@dataclass
class Customer:
    name: str
    email: str
    age: int = 0
  • @dataclass扫描具有类型提示的属性(name、email、age),并将其视为字段。
  • age: int = 0有默认值,就像函数中的默认参数一样。
  • 你无需编写__init__——它会根据字段的正确顺序自动生成。
c1 = Customer("An", "an@example.com")
print(c1)
Customer(name='An', email='an@example.com', age=0)

__repr__自动生成以完整显示字段名和值,调试时非常方便——通常类不会默认提供这个。

3. 与普通类和namedtuple的比较

手动编写等效的类会长得多:

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替你写完了所有这些。与namedtuple相比,dataclass的不同之处在于:

  • 对象默认是可变的(你可以修改c1.age = 20),namedtuple则不是。
  • 支持明确的类型提示,易于与mypy等类型检查器集成。
  • 支持__post_init__、复杂的默认值、继承——这些是namedtuple不擅长的。

注意: 如果你只需要一个轻量级的不可变元组,不需要方法,namedtuple或typing.NamedTuple仍然是更简洁的选择。

4. field()和可变默认值

一个很常见的错误:直接使用list/dict作为默认值。

from dataclasses import dataclass, field

@dataclass
class Order:
    order_id: str
    items: list[str] = field(default_factory=list)
  • 如果写items: list[str] = [],Python会报错ValueError: mutable default ... is not allowed——因为默认list会被所有实例共享,导致难以追踪的bug。
  • field(default_factory=list)要求dataclass为每个实例调用一个新的list()。
o1 = Order("A1")
o2 = Order("A2")
o1.items.append("Sách")

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

两个对象有独立的items,完全符合预期。

5. 使用__post_init__验证数据

dataclass生成的__init__不允许你直接插入验证逻辑——但有一个__post_init__钩子在__init__完成后立即运行。

from dataclasses import dataclass

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

    def __post_init__(self):
        if self.amount <= 0:
            raise ValueError("amount必须大于0")
        self.currency = self.currency.upper()
  • __post_init__会在完成所有字段赋值后自动调用。
  • 这里我们验证amount并将currency规范化为大写。
try:
    PaymentRequest(amount=-100)
except ValueError as e:
    print(e)
amount必须大于0

6. 使用frozen=True创建不可变类

如果你想在创建后无法修改对象(类似Java中的record),使用frozen=True。

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float
  • frozen=True会阻止__setattr__——在初始化后的任何赋值都会引发错误。
  • 由于具有不可变性,frozen对象可以用作字典的键或集合中的元素(因为当frozen=True且eq=True时,dataclass会自动生成__hash__)。
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'

注意: frozen=True只阻止顶级属性赋值。如果字段是list/dict(可变的),其内部内容仍然可以修改。要实现真正的不可变性,请对该字段使用tuple或不可变类型。

7. dataclass继承

Dataclass支持普通的Python类继承,子类的字段接在父类字段之后。

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

@dataclass
class Employee(Person):
    salary: float = 0.0
  • Employee从Person继承name和age,然后添加salary。
  • 生成的__init__中的参数顺序是:name、age、salary——保持声明顺序。
e = Employee("Bình", 30, salary=15_000_000)
print(e)
Employee(name='Bình', age=30, salary=15000000.0)

注意: 在整个继承链中,没有默认值的字段必须在有默认值的字段之前。如果父类有带默认值的字段,但子类添加了没有默认值的字段,Python在定义类时会抛出TypeError。

8. 使用order=True进行比较和排序

默认情况下,dataclass只生成__eq__。要进行<、>、<=比较,使用order=True。

from dataclasses import dataclass

@dataclass(order=True)
class Product:
    price: float
    name: str = field(compare=False)
  • order=True生成额外的__lt__、__le__、__gt__、__ge__,按字段顺序比较(类似元组比较)。
  • field(compare=False)将name从比较中排除——只按price比较。
products = [Product(50_000, "笔"), Product(20_000, "笔记本")]
print(sorted(products))
[Product(price=20000, name='笔记本'), Product(price=50000, name='笔')]

sorted()使用dataclass生成的__lt__,无需手动编写key=lambda p: p.price。

9. 转换为dict和tuple

dataclasses提供了asdict和astuple实用函数来序列化对象。

from dataclasses import dataclass, asdict, astuple

@dataclass
class Address:
    city: str
    country: str

@dataclass
class Customer2:
    name: str
    address: Address
cust = Customer2("Chi", Address("河内", "越南"))
print(asdict(cust))
print(astuple(cust))
{'name': 'Chi', 'address': {'city': '河内', 'country': '越南'}}
('Chi', ('河内', '越南'))
  • asdict递归地将嵌套的dataclass(Address)转换为子dict——非常便于通过API返回JSON。
  • astuple类似,但返回嵌套的元组。

注意: asdict/astuple会深拷贝所有数据。对于大对象或包含无法复制的字段(文件句柄、连接等)的对象,调用这些函数可能会很慢或出错。

10. 为dataclass编写测试

因为__eq__根据字段值自动生成,使用pytest进行对象比较测试非常自然。

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检查两个不同实例但字段值相同的对象仍然==——这要归功于自动生成的__eq__。
  • test_payment_request_rejects_non_positive_amount验证__post_init__正确验证。
  • test_point_is_frozen确认frozen=True阻止属性赋值。

11. 常见错误和何时不应该使用

  • 忘记为可变默认值使用default_factory:直接使用= []或= {}在声明类时会立即出错,必须使用field(default_factory=...)。
  • 在继承时混淆有默认值和无默认值的字段顺序:导致模块加载时出现TypeError,如果类分散在多个文件中会很难调试。
  • 认为frozen=True是深层不可变的:list/dict类型的字段内部内容仍然可以修改,只是不能重新赋值属性。
  • 将dataclass用于复杂的业务逻辑:如果类有许多处理方法,与其他对象的关系复杂,dataclass只会帮助初始化/比较部分——逻辑部分你仍然需要自己编写,不要滥用它来“假装”类比实际上更简单。
  • 需要兼容Python < 3.7:dataclasses不是内置的,必须从PyPI安装dataclasses包(反向移植)或使用attrs。

注意: 如果需要复杂的验证、自定义序列化或与JSON schema深度集成,pydantic等库通常比纯dataclasses更强大——在大型项目中值得考虑。

12. 结论

dataclasses帮助你在编写数据类时消除大部分样板代码:自动生成__init__、__repr__、__eq__,通过__post_init__支持验证,通过frozen=True支持不可变性,并通过asdict/astuple轻松序列化。对于简单到中等的数据类,这比手工编写的选择要简洁和易读得多。当验证或序列化需求更复杂时,你可以考虑用pydantic或attrs替代。

pythondataclassesoopclean-code