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替代。