Skip to content

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 的传统写法:

python
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_atdatetime.now 需要特殊处理)

用 @dataclass 改写:

python
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 自动生成了什么

python
from dataclasses import dataclass

@dataclass
class BorrowRecord:
    member_id: str
    isbn: str
    returned_at: datetime | None = field(default=None)

等同于手动写了:

python
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=Trueunsafe_hash=True基于字段元组计算哈希
__lt__order=True基于字段元组比较大小

field() 高级配置

demo_ch07 实战oop_demo/app/domain/record.py):

python
@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)字段默认值为 Nonedefault 用于不可变默认值,default_factory 用于可变默认值

post_init 验证钩子

python
@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 不可变数据类

python
@dataclass(frozen=True)
class IsbnSnapshot:
    isbn: str
    title: str
    author: str
效果说明
禁止修改字段snap.title = "other"FrozenInstanceError
自动生成 __hash__可用作 dict key 和 set 元素
替代 __slots__不可变对象天然安全,无需担心哈希变化

demo_ch07 验证:

python
snap = IsbnSnapshot("9780000000001", "Python", "Guido")
hash(snap)           # 可哈希
d = {snap: "cached"} # 可作字典键
# snap.title = "X"   # FrozenInstanceError ❌

贯穿实战

运行 demo_ch07 查看完整演示:

bash
cd oop_demo && uv run python -m app
# 选择 7. 数据类(@dataclass)

或用测试验证:

bash
cd oop_demo && uv run pytest -k dataclass -v

测试覆盖:

  • test_borrow_record_auto_init — 自动 __init__
  • test_borrow_record_default_factory_fieldsdefault_factory 独立性
  • test_borrow_record_post_init_validation__post_init__ 验证
  • test_borrow_record_complete_return — 实例方法
  • test_borrow_record_dataclass_eq — 自动 __eq__
  • test_isbn_snapshot_frozen_hashablefrozen=True 可哈希
  • test_isbn_snapshot_frozen_immutablefrozen=True 不可变

@dataclass vs 普通 class vs TypedDict

维度@dataclass普通 classTypedDict
运行时类型真正的类真正的类仍是 dict
__init__自动生成手动写无(直接构造 dict)
__repr__自动生成手动写
__eq__按字段比较按 id 比较按 dict 内容
可加方法
类型检查✅(仅静态)
适用场景内部数据结构有行为的对象JSON/API 数据

选择指南:

需要方法?
  ├── 是 → 有复杂业务逻辑?
  │         ├── 是 → 普通 class
  │         └── 否 → @dataclass
  └── 否 → 数据来自 JSON/API?
            ├── 是 → TypedDict
            └── 否 → @dataclass

L2 实践层:用好

推荐做法

做法原因示例
纯数据容器用 @dataclass零样板代码BorrowRecord
不可变数据用 frozen=True防止意外修改,可哈希IsbnSnapshot
可变默认值用 default_factory避免共享陷阱field(default_factory=list)
验证逻辑放 __post_init__集中后处理日期校验、范围检查
大量实例用 slots=True内存优化(Python 3.10+)@dataclass(slots=True)
需要 JSON 序列化用 Pydanticdataclass 无运行时验证BaseModel
有复杂业务逻辑用普通 classdataclass 侧重数据而非行为Library 服务类
派生字段用 __post_init__ 计算惰性初始化self.full_name = f"{first} {last}"

反模式:不要这样做

python
# ❌ 可变默认值直接写在字段定义 — 所有实例共享
@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())
python
# ❌ @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__ 末尾调用  │
    └────────────────────────────────────────────────┘
python
# 验证:查看 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__ 构建器的内部生成逻辑(简化):

python
# @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) 执行生成函数对象
    pass

slots=True 的工作原理:

python
@dataclass(slots=True)
class Point:
    x: float
    y: float

# 等效于:
class Point:
    __slots__ = ('x', 'y')
    # __init__, __repr__, __eq__ 仍然自动生成

# 效果:
# - 实例不再拥有 __dict__,节省内存
# - 无法动态添加属性
# - 每个实例内存开销约减少 50%
python
# 验证 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__
python
# 性能对比:@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__ │     描述符协议实现字段访问
   └───────────┘

自检清单

  1. @dataclass 自动生成哪些方法?

    • __init____repr____eq__;可选 __hash__(frozen)、__lt__ 等(order)
  2. 可变默认值为什么要用 default_factory

    • items: list = [] 会让所有实例共享同一个列表;field(default_factory=list) 每次创建新实例时调用 list() 生成独立对象
  3. __post_init__ 在什么时候执行?

    • __init__ 完成所有字段赋值后自动调用,用于验证和派生字段计算
  4. frozen=True 的 dataclass 有什么好处?

    • 字段不可修改,自动生成 __hash__,可用作 dict key 和 set 元素,线程安全
  5. 什么时候不该用 @dataclass?

    • 需要运行时数据验证(用 Pydantic)、纯字典数据传输(用 TypedDict)、有大量业务逻辑(用普通 class)

本章能力清单

学完本章后,你可以:

  • [ ] 用 @dataclass 声明数据容器,消除样板代码
  • [ ] 用 field(default_factory=...) 处理可变默认值
  • [ ] 用 __post_init__ 实现字段验证和派生计算
  • [ ] 用 frozen=True 创建不可变、可哈希的数据类
  • [ ] 在 @dataclass、普通 class、TypedDict 之间做正确选择