07-数据类
Python 版本要求:Python 3.11+ 贯穿项目:oop_demo/ 代码位置:
oop_demo/app/domain/record.py测试验证:cd oop_demo && uv run pytest -k dataclass -v
概念铺垫
为什么需要数据类?
问题场景
你需要定义一个借阅记录类,存储会员 ID、ISBN、借阅时间、应还日期等信息。
不用 @dataclass 的传统写法:
from datetime import datetime, timedelta
class BorrowRecord:
def __init__(
self,
member_id: str,
isbn: str,
borrowed_at: datetime | None = None,
due_date: datetime | None = None,
returned_at: datetime | None = None,
) -> None:
self.member_id = member_id
self.isbn = isbn
self.borrowed_at = borrowed_at or datetime.now()
self.due_date = due_date or (datetime.now() + timedelta(days=14))
self.returned_at = returned_at
def __repr__(self) -> str:
return (
f"BorrowRecord(member_id={self.member_id!r}, isbn={self.isbn!r}, "
f"borrowed_at={self.borrowed_at}, due_date={self.due_date}, "
f"returned_at={self.returned_at})"
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, BorrowRecord):
return NotImplemented
return (
self.member_id == other.member_id
and self.isbn == other.isbn
and self.borrowed_at == other.borrowed_at
and self.due_date == other.due_date
and self.returned_at == other.returned_at
)问题:
__init__、__repr__、__eq__大量样板代码- 每增加一个字段,三个方法都要改
- 可变默认值陷阱(如
borrowed_at用datetime.now需要特殊处理)
用 @dataclass 改写:
from dataclasses import dataclass, field
from datetime import datetime, timedelta
@dataclass
class BorrowRecord:
member_id: str
isbn: str
borrowed_at: datetime = field(default_factory=datetime.now)
due_date: datetime = field(default_factory=lambda: datetime.now() + timedelta(days=14))
returned_at: datetime | None = field(default=None)一行 @dataclass,自动获得 __init__、__repr__、__eq__。这就是数据类的价值:用声明式字段定义替代重复的样板代码。
生活类比
@dataclass 就像"预制菜包装" — 所有调料、配菜、烹饪步骤都配好了,开箱即用。你只需要告诉它"我要什么食材(字段)",它自动帮你完成清洗、切配、装盘(生成 __init__、__repr__、__eq__)。相比之下,普通 class 就像从零开始做菜,每个步骤都要手动完成。
L1 理解层:会用
核心原理
@dataclass 自动生成了什么
from dataclasses import dataclass
@dataclass
class BorrowRecord:
member_id: str
isbn: str
returned_at: datetime | None = field(default=None)等同于手动写了:
class BorrowRecord:
def __init__(self, member_id: str, isbn: str, returned_at: datetime | None = None) -> None:
self.member_id = member_id
self.isbn = isbn
self.returned_at = returned_at
def __repr__(self) -> str:
return f"BorrowRecord(member_id={self.member_id!r}, isbn={self.isbn!r}, returned_at={self.returned_at!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, BorrowRecord):
return NotImplemented
return (self.member_id, self.isbn, self.returned_at) == (other.member_id, other.isbn, other.returned_at)自动生成规则:
| 方法 | 触发条件 | 行为 |
|---|---|---|
__init__ | 始终生成 | 按字段顺序接受参数并赋值 |
__repr__ | 始终生成 | 类名(字段1=值, 字段2=值, ...) |
__eq__ | 始终生成 | 逐个字段比较 |
__hash__ | frozen=True 或 unsafe_hash=True | 基于字段元组计算哈希 |
__lt__ 等 | order=True | 基于字段元组比较大小 |
field() 高级配置
demo_ch07 实战(oop_demo/app/domain/record.py):
@dataclass
class BorrowRecord:
member_id: str
isbn: str
borrowed_at: datetime = field(default_factory=datetime.now)
due_date: datetime = field(default_factory=lambda: datetime.now() + timedelta(days=14))
returned_at: datetime | None = field(default=None)| 代码 | 含义 | 为什么这样写 |
|---|---|---|
field(default_factory=datetime.now) | 每次创建实例时调用 datetime.now() | 避免所有实例共享同一个时间点(可变默认值陷阱) |
field(default_factory=lambda: ...) | 用 lambda 包装复杂表达式 | default_factory 接受无参可调用对象 |
field(default=None) | 字段默认值为 None | default 用于不可变默认值,default_factory 用于可变默认值 |
post_init 验证钩子
@dataclass
class BorrowRecord:
member_id: str
isbn: str
borrowed_at: datetime = field(default_factory=datetime.now)
due_date: datetime = field(default_factory=lambda: datetime.now() + timedelta(days=14))
returned_at: datetime | None = field(default=None)
def __post_init__(self) -> None:
if self.due_date <= self.borrowed_at:
raise ValueError("还书日期必须晚于借阅日期")执行顺序:
__init__(参数赋值) → __post_init__() → 实例可用
↓ ↓
self.borrowed_at = xxx 验证 due_date > borrowed_at
self.due_date = xxx 失败则抛异常,实例不会"流出"关键规则:__post_init__ 在所有字段赋值完成后调用,适合做验证和派生字段计算。
frozen=True 不可变数据类
@dataclass(frozen=True)
class IsbnSnapshot:
isbn: str
title: str
author: str| 效果 | 说明 |
|---|---|
| 禁止修改字段 | snap.title = "other" → FrozenInstanceError |
自动生成 __hash__ | 可用作 dict key 和 set 元素 |
替代 __slots__ | 不可变对象天然安全,无需担心哈希变化 |
demo_ch07 验证:
snap = IsbnSnapshot("9780000000001", "Python", "Guido")
hash(snap) # 可哈希
d = {snap: "cached"} # 可作字典键
# snap.title = "X" # FrozenInstanceError ❌贯穿实战
运行 demo_ch07 查看完整演示:
cd oop_demo && uv run python -m app
# 选择 7. 数据类(@dataclass)或用测试验证:
cd oop_demo && uv run pytest -k dataclass -v测试覆盖:
test_borrow_record_auto_init— 自动__init__test_borrow_record_default_factory_fields—default_factory独立性test_borrow_record_post_init_validation—__post_init__验证test_borrow_record_complete_return— 实例方法test_borrow_record_dataclass_eq— 自动__eq__test_isbn_snapshot_frozen_hashable—frozen=True可哈希test_isbn_snapshot_frozen_immutable—frozen=True不可变
@dataclass vs 普通 class vs TypedDict
| 维度 | @dataclass | 普通 class | TypedDict |
|---|---|---|---|
| 运行时类型 | 真正的类 | 真正的类 | 仍是 dict |
__init__ | 自动生成 | 手动写 | 无(直接构造 dict) |
__repr__ | 自动生成 | 手动写 | 无 |
__eq__ | 按字段比较 | 按 id 比较 | 按 dict 内容 |
| 可加方法 | ✅ | ✅ | ❌ |
| 类型检查 | ✅ | ✅ | ✅(仅静态) |
| 适用场景 | 内部数据结构 | 有行为的对象 | JSON/API 数据 |
选择指南:
需要方法?
├── 是 → 有复杂业务逻辑?
│ ├── 是 → 普通 class
│ └── 否 → @dataclass
└── 否 → 数据来自 JSON/API?
├── 是 → TypedDict
└── 否 → @dataclassL2 实践层:用好
推荐做法
| 做法 | 原因 | 示例 |
|---|---|---|
纯数据容器用 @dataclass | 零样板代码 | BorrowRecord |
不可变数据用 frozen=True | 防止意外修改,可哈希 | IsbnSnapshot |
可变默认值用 default_factory | 避免共享陷阱 | field(default_factory=list) |
验证逻辑放 __post_init__ | 集中后处理 | 日期校验、范围检查 |
大量实例用 slots=True | 内存优化(Python 3.10+) | @dataclass(slots=True) |
| 需要 JSON 序列化用 Pydantic | dataclass 无运行时验证 | BaseModel |
| 有复杂业务逻辑用普通 class | dataclass 侧重数据而非行为 | Library 服务类 |
派生字段用 __post_init__ 计算 | 惰性初始化 | self.full_name = f"{first} {last}" |
反模式:不要这样做
# ❌ 可变默认值直接写在字段定义 — 所有实例共享
@dataclass
class Library:
books: list = [] # 危险!所有实例共享同一个列表
# ✅ 用 default_factory
@dataclass
class Library:
books: list = field(default_factory=list)
# ❌ 在 __post_init__ 中修改 frozen dataclass 的字段
@dataclass(frozen=True)
class Config:
host: str
def __post_init__(self):
self.host = self.host.lower() # FrozenInstanceError!
# ✅ 用 object.__setattr__ 绕过(已知技巧)
@dataclass(frozen=True)
class Config:
host: str
def __post_init__(self):
object.__setattr__(self, 'host', self.host.lower())# ❌ @dataclass 用于有大量业务逻辑的类
@dataclass
class Library:
books: dict = field(default_factory=dict)
def borrow(self, ...): # 复杂的业务逻辑
...
def return_item(self, ...):
...
def generate_report(self, ...):
...
# ✅ 普通 class 用于有行为的对象
class Library:
def __init__(self):
self._books: dict[str, BookItem] = {}
# 各种业务方法...适用场景
| 场景 | 是否推荐 | 原因 |
|---|---|---|
| 数据库记录 / API 响应模型 | 推荐 @dataclass | 纯数据容器 |
| 不可变的值对象(如坐标、配置) | 推荐 frozen=True | 安全、可哈希 |
| 需要在 set/dict 中使用的对象 | 推荐 frozen=True | 自动 hash |
| 复杂业务服务类 | 不推荐 | 用普通 class |
| 需要运行时验证 | 不推荐 | 用 Pydantic |
| 大量实例(百万级) | 推荐 slots=True | 节省约 50% 内存 |
L3 专家层:深入
Python 如何实现:@dataclass 代码生成机制
@dataclass 装饰器在类定义时动态生成方法:
@dataclass
class BorrowRecord:
member_id: str
isbn: str
类定义过程:
┌────────────────────────────────────────────────┐
│ 1. Python 执行类体,收集字段定义 │
│ 2. @dataclass 装饰器被调用 │
│ 3. _process_class(cls, ...) 分析字段 │
│ - 收集类型注解 (__annotations__) │
│ - 收集 field() 定义 │
│ - 判断每个字段的默认值 │
│ 4. _init_fn(fields, ...) 生成 __init__ 源码 │
│ - 拼接参数列表 │
│ - 有默认值的字段排在后面 │
│ - 生成 self.xxx = xxx 赋值语句 │
│ 5. _repr_fn(fields) 生成 __repr__ 源码 │
│ 6. _eq_fn(fields) 生成 __eq__ 源码 │
│ 7. exec() 生成的代码,绑定到类 │
│ 8. 如果有 __post_init__,在 __init__ 末尾调用 │
└────────────────────────────────────────────────┘# 验证:查看 dataclass 生成的 __init__ 源码
from dataclasses import dataclass
import inspect
@dataclass
class Point:
x: float
y: float = 0.0
print(inspect.getsource(Point.__init__))
# 在 Python 3.11+ 中 dataclass 使用 exec() 生成方法,
# inspect.getsource 可能无法直接获取(动态生成的代码无文件来源)
# 但可以通过 dis 查看字节码验证
import dis
dis.dis(Point.__init__)__init__ 构建器的内部生成逻辑(简化):
# @dataclass 内部等价于执行了类似这样的代码:
def _create_init(fields):
"""生成 __init__ 函数的字符串源码"""
# 1. 将字段分为必填和有默认值的
required = [f for f in fields if f.default is MISSING]
optional = [f for f in fields if f.default is not MISSING]
# 2. 拼接参数列表
# def __init__(self, 必填1: type1, 必填2: type2, 可选1: type3 = default3, ...)
# 3. 生成赋值语句
# self.必填1 = 必填1
# self.必填2 = 必填2
# self.可选1 = 可选1
# ...
# 4. 如果定义了 __post_init__:
# self.__post_init__()
# 5. exec(源码, globals, locals) 执行生成函数对象
passslots=True 的工作原理:
@dataclass(slots=True)
class Point:
x: float
y: float
# 等效于:
class Point:
__slots__ = ('x', 'y')
# __init__, __repr__, __eq__ 仍然自动生成
# 效果:
# - 实例不再拥有 __dict__,节省内存
# - 无法动态添加属性
# - 每个实例内存开销约减少 50%# 验证 slots=True 的内存优化
import sys
from dataclasses import dataclass
@dataclass
class Normal:
a: int = 0
b: float = 0.0
c: str = ""
@dataclass(slots=True)
class WithSlots:
a: int = 0
b: float = 0.0
c: str = ""
n = Normal()
s = WithSlots()
print(sys.getsizeof(n)) # ~48 bytes
print(sys.getsizeof(s)) # ~48 bytes(相同基础大小)
# 但 Normal 还有额外的 __dict__:
print(sys.getsizeof(n.__dict__)) # ~104 bytes
# 创建 100000 个实例:
# Normal: ~15 MB(含 __dict__)
# WithSlots: ~5 MB(节省约 66%)性能考量
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| @dataclass 装饰(类定义时) | O(n) | n=字段数,生成方法源码 + exec() |
实例化 __init__ | O(n) | 逐个字段赋值 + __post_init__ |
__repr__ | O(n) | 拼接所有字段的字符串表示 |
__eq__ | O(n) | 逐个字段比较 |
| slots=True 属性访问 | O(1) | 比 __dict__ 查找快约 20% |
| frozen=True 实例化 | O(n) | 同普通 init,赋值用 object.__setattr__ |
# 性能对比:@dataclass vs 手动 class
import timeit
from dataclasses import dataclass
@dataclass
class DataPoint:
x: float
y: float
z: float
class ManualPoint:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
def __repr__(self):
return f"ManualPoint(x={self.x!r}, y={self.y!r}, z={self.z!r})"
def __eq__(self, other):
if not isinstance(other, ManualPoint):
return NotImplemented
return (self.x, self.y, self.z) == (other.x, other.y, other.z)
# 实例化性能几乎相同
print(timeit.timeit("DataPoint(1.0, 2.0, 3.0)",
globals={'DataPoint': DataPoint}, number=100000))
print(timeit.timeit("ManualPoint(1.0, 2.0, 3.0)",
globals={'ManualPoint': ManualPoint}, number=100000))
# 两者差异在 5% 以内知识关联
数据类知识关联:
┌───────────────┐
│ 第 1 章:类 │
│ 手动 __init__ │
└───────┬───────┘
│
↓
┌───────────────┐
│ 第 7 章:数据类│
│ @dataclass │
│ 代码生成 │
└───────┬───────┘
│
┌────────────┼────────────┐
↓ ↓ ↓
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 自动 │ │ __post_ │ │ frozen=True│
│ __init__ │ │ __init__ │ │ 不可变 │
│ __repr__ │ │ 验证 │ │ 可哈希 │
│ __eq__ │ │ │ │ │
└───────────┘ └───────────┘ └───────────┘
│
↓
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 普通 class│ │ TypedDict │ │ Pydantic │
│ 有业务逻辑│ │ 类型字典 │ │ 运行时验证 │
└───────────┘ └───────────┘ └───────────┘
│
↓
┌───────────┐
│ slots=True│ ← 无 __dict__ 的内存布局
│ __slots__ │ 描述符协议实现字段访问
└───────────┘自检清单
@dataclass 自动生成哪些方法?
__init__、__repr__、__eq__;可选__hash__(frozen)、__lt__等(order)
可变默认值为什么要用
default_factory?items: list = []会让所有实例共享同一个列表;field(default_factory=list)每次创建新实例时调用list()生成独立对象
__post_init__在什么时候执行?- 在
__init__完成所有字段赋值后自动调用,用于验证和派生字段计算
- 在
frozen=True的 dataclass 有什么好处?- 字段不可修改,自动生成
__hash__,可用作 dict key 和 set 元素,线程安全
- 字段不可修改,自动生成
什么时候不该用 @dataclass?
- 需要运行时数据验证(用 Pydantic)、纯字典数据传输(用 TypedDict)、有大量业务逻辑(用普通 class)
本章能力清单
学完本章后,你可以:
- [ ] 用
@dataclass声明数据容器,消除样板代码 - [ ] 用
field(default_factory=...)处理可变默认值 - [ ] 用
__post_init__实现字段验证和派生计算 - [ ] 用
frozen=True创建不可变、可哈希的数据类 - [ ] 在
@dataclass、普通 class、TypedDict之间做正确选择