为什么需要数据类
业务代码里到处是「装着字段的对象」:订单、用户、配置项、接口返回的模型。用普通类写,每个都要手写 `__init__`、`__repr__`、`__eq__`,字段一多代码就膨胀,且容易在复制粘贴时漏掉某个字段。用字典替代又会失去属性访问的便利和类型约束。
Python 3.7 引入的 `dataclasses` 模块就是为解决这个问题:只要声明类变量并标注类型,`@dataclass` 装饰器会自动生成 `__init__`、`__repr__`、`__eq__` 等方法,让「数据容器」类的编写成本降到最低。
from dataclasses import dataclass
@dataclass
class Product:
sku: str
name: str
price: float
stock: int = 0
这段代码生成的类自带初始化、打印和相等比较,三个字段加一个默认值,语义一目了然。数据类把「数据长什么样」和「行为是什么」分开:纯数据用数据类,复杂行为再叠方法或组合。
数据类解决的不是性能问题,而是代码表达问题:让数据模型本身清晰可见。
- @dataclass自动生成样板方法。
- field精细字段配置。
- frozen不可变数据类。
- property属性校验与派生。
@dataclass 基础用法
`@dataclass` 默认按「无默认值的字段在前、有默认值的在后」的规则生成构造方法,这与 Python 函数参数的规则一致。实例化时用位置参数或关键字参数都可以,打印时得到可读的 `Product(sku='A1', name='键盘', price=299.0, stock=20)`。
p1 = Product("A1", "键盘", 299.0, 20)
p2 = Product(sku="A1", name="键盘", price=299.0, stock=20)
print(p1)
print(p1 == p2)
数据类默认参与相等比较的是全部字段;想只按某个业务主键比较,可以在 `@dataclass(eq=True)` 基础上重写 `__eq__`,或者用 `field(compare=False)` 排除不参与比较的字段。字段顺序会直接影响 `__init__` 的参数顺序,定义时把稳定、必填的字段放前面。
字段配置与默认值
默认值不是只能写字面量。列表、字典这类可变对象直接作为默认值是 Python 的经典陷阱:所有实例共享同一个列表,改一个全变。数据类用 `field(default_factory=list)` 这类工厂写法让每个实例各自创建新容器。
from dataclasses import dataclass, field
@dataclass
class Cart:
owner: str
items: list = field(default_factory=list)
tags: dict = field(default_factory=dict)
c1 = Cart("小明")
c1.items.append("键盘")
c2 = Cart("小红")
print(c2.items) # 输出空列表,两个实例互不影响
`field` 还能控制字段的附加行为:`init=False` 表示不进构造方法,常配合 `default_factory` 存派生数据;`repr=False` 让敏感或冗余字段不进打印输出;`compare=False` 排除在相等比较之外;`metadata` 可以挂业务元数据,供工具读取。
frozen 与不可变数据类
把 `frozen=True` 传给装饰器,数据类实例就变得不可变:任何赋值都会抛 `FrozenInstanceError`。不可变对象天然线程安全、可安全作字典键、方便做值语义比较,在多线程和函数式风格里非常受用。
@dataclass(frozen=True)
class Point:
x: float
y: float
pt = Point(1.0, 2.0)
pt.x = 3.0 # 抛 FrozenInstanceError
注意 `frozen` 只拦截属性赋值,不拦截容器内部修改:`Point` 里如果放了一个列表字段,列表里的元素仍可增删。要彻底不可变,容器字段也要用元组这类不可变类型。Python 3.10 之后还能在 `@dataclass(slots=True)` 上开槽位存储,省内存、提访问速度,代价是类不能再有额外动态属性。
property 与属性校验
数据类适合装纯数据,但真实业务往往要在赋值时做校验或派生:价格不能为负、总量要缓存计算、格式化字段要按规则输出。`property` 把「看似普通的属性访问」接到方法上,读时计算、写时校验,对外接口不变。
@dataclass
class Order:
unit_price: float
quantity: int
@property
def total(self):
return self.unit_price * self.quantity
@total.setter
def total(self, value):
if value < 0:
raise ValueError("金额不能为负")
self.unit_price = value / self.quantity
更强的做法是在 `__post_init__` 里做跨字段校验:这个钩子在 `__init__` 末尾自动执行,适合检查「结束时间晚于开始时间」「折扣不超过原价」这类多个字段的组合约束,校验失败直接抛异常,让非法对象在诞生那一刻就被拦住。
序列化与类型系统配合
数据类要和外部世界打交道,免不了转字典、转 JSON。标准做法是 `asdict` 与 `astuple`:前者把嵌套数据类递归转成字典,后者转成元组。反向构造用 `**kwargs` 展开即可。搭配 Pydantic 时,数据类可以整体升级为带运行时校验的模型,字段错误在解析输入那一刻就报出来。
from dataclasses import asdict
@dataclass
class Address:
city: str
street: str
@dataclass
class User:
name: str
addr: Address
u = User("小林", Address("佛山", "季华路"))
print(asdict(u))
类型系统层面,`ClassVar` 表示类变量而非实例字段,`InitVar` 表示「只在初始化用、不存进实例」的临时参数。把这些写清楚,mypy 和 IDE 就能帮你提前发现字段拼写、类型不匹配等问题,数据模型越改越稳。
实战建议与性能陷阱
- 可变默认值一律用 `field(default_factory=list)` 这类工厂写法,不要直接写空列表。
- 字段很多的类优先加 `slots=True`,内存占用和访问速度都更优。
- 只读数据模型用 `frozen=True`,防止调用方悄悄改坏共享对象。
- 跨字段校验放 `__post_init__`,单字段校验用 `property` setter。
- 与外部系统转换统一走 `asdict` 或序列化库,别手写字典拼接。
- 继承数据类时注意字段顺序,父类字段默认在前,容易踩参数错位。
好的数据模型让业务规则显性化:非法状态在构造时被拒绝,而不是在使用时爆炸。