小白学 Python
AI 编程指南GitHub
© 2026 小白学 Python · 基于 walter201230/Python 教程
课程目录关于本站联系方式隐私政策GitHub

dataclass

完整讲解 · 5 段教学·配 4 道练习题·预计 35 分钟

本页是本章的通读版,可直接读完全部讲解。想动手写代码、跑判分,去 闯关模式。

教学 01 / 05

第二十节:dataclass

写代码这事,一不留神就会陷入「样板代码」的泥潭。先做个小实验:现在请你写一个 Employee 类,要求是这样的——存员工的姓名、部门、工号、入职日期、月薪,要能正常打印(不能是 <__main__.Employee object at 0x...> 这种鬼东西),要能比较两个员工对象是否相等,最好还能拿出来做字典的 key。

听起来不难是吧?大概长这样:

python到闯关页运行这段 →
class Employee:
    def __init__(self, name, dept, emp_id, hire_date, salary):
        self.name = name
        self.dept = dept
        self.emp_id = emp_id
        self.hire_date = hire_date
        self.salary = salary

    def __repr__(self):
        return (
            f'Employee(name={self.name!r}, dept={self.dept!r}, '
            f'emp_id={self.emp_id!r}, hire_date={self.hire_date!r}, '
            f'salary={self.salary!r})'
        )

    def __eq__(self, other):
        if not isinstance(other, Employee):
            return NotImplemented
        return (
            self.name == other.name
            and self.dept == other.dept
            and self.emp_id == other.emp_id
            and self.hire_date == other.hire_date
            and self.salary == other.salary
        )

    def __hash__(self):
        return hash((self.name, self.dept, self.emp_id, self.hire_date, self.salary))

数一下,光这么一个普普通通的「数据类」,就花了二十多行。__init__ 写一遍字段,__repr__ 写一遍字段,__eq__ 写一遍字段,__hash__ 又写一遍字段——同一组字段名重复出现了五次。再想象一下你这个类有 15 个字段,那 __init__ 的参数列表就要排成一列火车,每个 self.xxx = xxx 都要复制粘贴,写到第十个就开始想骂人。

这就是「样板代码」

它没创造任何业务价值,纯粹是 Python 语法要求你必须这么写。写代码的人讨厌它,看代码的人也讨厌它,因为信息密度太低,真正重要的「这个类有哪些字段」被淹没在 self.xxx = xxx 的重复噪声里。

那有没有什么办法,能让我们只声明字段,剩下的活儿让 Python 自己干?

有。Python 3.7 给我们送来了 dataclass。从 3.10 起又给它加了 slots、kw_only 等更现代的开关。这一节,我们就把 dataclass 这条线从基础用法一路捋到现代写法,让各位写数据结构的时候,再也不用手指头打结。

这一节要学什么

  • 基础:@dataclass 三行代码搞定 __init__ / __repr__ / __eq__
  • 默认值:普通默认值 + field(default_factory=...) 处理可变默认值
  • frozen:让对象不可变,副产品是能进 set、能当 dict key
  • 比较:__eq__ 自动按字段值比较

学完之后,再写数据类基本上就是「贴一个装饰器、列一下字段」这种轻松活儿。

教学 02 / 05

一、第一个 dataclass

把上面那个 Employee 类,用 dataclass 重写一遍:

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass
class Employee:
    name: str
    dept: str
    emp_id: int
    hire_date: str
    salary: int


e = Employee('两点水', '研发部', 1001, '2020-03-15', 12000)
print(e)

输出:

Employee(name='两点水', dept='研发部', emp_id=1001, hire_date='2020-03-15', salary=12000)

二十多行的代码,缩成了不到十行。@dataclass 这个装饰器一贴,Python 就帮我们干了这些事:

  • 看到 name: str、dept: str 这些「带类型注解的类变量」,自动当成字段
  • 自动生成一个 __init__,参数顺序就是字段顺序
  • 自动生成一个 __repr__,长得跟咱们手写的那种「类名(字段=值, 字段=值)」一模一样
  • 自动生成一个 __eq__,按字段逐个比较

整个过程,你只需要把字段名和它的类型写出来,剩下的全是 dataclass 在帮你干活。

类型注解必须有

那「类型注解」是不是必须的?是的。这是 dataclass 识别字段的依据。你如果只写 name = '' 而不写 name: str,dataclass 就认不出来——它会把 name 当成一个普通的类属性,不会进 __init__ 的参数列表。

记住一句话:在 dataclass 里,name: str 是「字段声明」,name = '默认值' 是「类属性」,两者作用截然不同。

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass
class Demo:
    a: int        # 这是字段,会进 __init__
    b: int = 10   # 这是有默认值的字段,也会进 __init__
    c = 20        # 注意:这里没有类型注解,被当成普通类属性,不会进 __init__


d = Demo(1)
print(d)
print(d.c)

输出:

Demo(a=1, b=10)
20

看到没?c 没出现在 repr 里,因为它根本不是一个字段,只是个挂在类上的常量。

比较两个 dataclass 对象

dataclass 默认会生成 __eq__,所以两个字段值完全相同的对象,会被判为相等:

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass
class Employee:
    name: str
    dept: str
    salary: int


a = Employee('两点水', '研发部', 12000)
b = Employee('两点水', '研发部', 12000)
c = Employee('两点水', '研发部', 15000)

print(a == b)
print(a == c)
print(a is b)

输出:

True
False
False

注意第三行——a is b 是 False。== 比的是「字段值是否相等」,is 比的是「是不是同一个对象」。这两个事完全两码事,别混。

练习 1 / 4用 @dataclass 定义 Point去闯关页做这题 →
教学 03 / 05

二、默认值与 field()

跟普通函数参数一样,dataclass 字段也能有默认值:

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass
class Employee:
    name: str
    dept: str = '研发部'
    salary: int = 8000


e1 = Employee('两点水')
e2 = Employee('小明', '设计部', 15000)
print(e1)
print(e2)

输出:

Employee(name='两点水', dept='研发部', salary=8000)
Employee(name='小明', dept='设计部', salary=15000)

有没有注意到,跟函数参数一个道理——「带默认值的字段」必须放在「不带默认值的字段」后面。下面这种写法,Python 会直接拍死:

python到闯关页运行这段 →
@dataclass
class BadOrder:
    qty: int = 1
    name: str   # 错!没默认值的字段不能跟在有默认值的后面

报错:

TypeError: non-default argument 'name' follows default argument

可变默认值的坑

那如果默认值是一个「可变对象」呢?比如默认值是个空列表:

python到闯关页运行这段 →
@dataclass
class Team:
    name: str
    members: list = []   # 直接报错

报错:

ValueError: mutable default <class 'list'> for field members is not allowed: use default_factory

Python 这次是个负责任的爹,直接把你拦在门外。为啥不让你写?因为如果允许,所有 Team 实例都会共享同一个 members 列表,往一个里 append,所有实例的 members 都会跟着变。这是 Python 一个非常老的坑了,老到 dataclass 设计的时候直接把这条路堵死。

解法:field(default_factory=...)

field() 是 dataclasses 模块里另一个主角。它专门用来给字段做更细的配置。最常见的用法就是「可变默认值」:

python到闯关页运行这段 →
from dataclasses import dataclass, field


@dataclass
class Team:
    name: str
    members: list = field(default_factory=list)


t1 = Team('A 队')
t2 = Team('B 队')
t1.members.append('两点水')
print(t1)
print(t2)

输出:

Team(name='A 队', members=['两点水'])
Team(name='B 队', members=[])

default_factory=list 的意思是:「每次创建实例的时候,调一下 list(),拿一个全新的空列表当默认值」。这样两个实例就各持一份自己的列表,互不干扰。

default_factory 还能接任何「无参数可调用对象」。比如:

python到闯关页运行这段 →
from dataclasses import dataclass, field


@dataclass
class Counter:
    name: str
    counts: dict = field(default_factory=dict)
    tags: set = field(default_factory=set)
    history: list = field(default_factory=lambda: ['初始记录'])


c = Counter('点击计数')
print(c)

输出:

Counter(name='点击计数', counts={}, tags=set(), history=['初始记录'])

最后一个 history 是 default_factory=lambda: ['初始记录']。这种「每次都返回一个有初始值的列表」也是常见用法。

field() 还能干什么

field() 还有几个常用参数:

python到闯关页运行这段 →
from dataclasses import dataclass, field


@dataclass
class Product:
    name: str
    price: float
    discount: float = field(default=0.0)             # 跟 = 0.0 等价,但写法更显式
    tags: list = field(default_factory=list)         # 可变默认值
    internal_id: str = field(repr=False, default='') # 不在 repr 里显示
    cache: dict = field(default_factory=dict, compare=False)  # 不参与 == 比较

挑两个最有用的讲讲:

  • repr=False:这个字段不会出现在 repr 里。适合放敏感信息(密码、token)或者你不想被打印出来污染日志的内部状态
  • compare=False:这个字段不参与 __eq__ 比较。适合放那种「不影响业务身份」的辅助字段,比如缓存、临时计数器
练习 2 / 4可变默认值:default_factory去闯关页做这题 →
教学 04 / 05

三、不可变 dataclass——frozen=True

默认的 dataclass 实例是「可变」的,你随时能给字段重新赋值:

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass
class Point:
    x: int
    y: int


p = Point(1, 2)
p.x = 100
print(p)

输出:

Point(x=100, y=2)

但有时候你希望对象创建之后就不再变了——比如「坐标点」、「枚举值」、「配置项」这种概念上就该是只读的东西。这时候就该 frozen=True 上场:

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass(frozen=True)
class Point:
    x: int
    y: int


p = Point(1, 2)
print(p)

try:
    p.x = 100
except Exception as e:
    print(type(e).__name__, ':', e)

输出:

Point(x=1, y=2)
FrozenInstanceError : cannot assign to field 'x'

赋值就抛 FrozenInstanceError。这个保护是「运行时」的——也就是说就算静态检查工具没拦住你,运行时也会炸。

frozen 的副产品:自动 hash

frozen=True 还有一个重要的副作用:Python 会顺便给你生成 __hash__,让这个对象能放进 set、能当 dict 的 key。

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass(frozen=True)
class Point:
    x: int
    y: int


p1 = Point(1, 2)
p2 = Point(1, 2)
p3 = Point(3, 4)

s = {p1, p2, p3}
print(len(s))

mapping = {Point(0, 0): '原点', Point(1, 0): '右一格'}
print(mapping[Point(0, 0)])

输出:

2
原点

p1 和 p2 字段值一样,被 set 当成了同一个元素,最后 len(s) 是 2 不是 3。Point(0, 0) 还能拿来当 dict 的 key,新建一个相同字段的 Point 也能精确查到——这正是「frozen + dataclass 自动生成的 hash」加在一起的效果。

为什么必须 frozen 才能 hash?

那不加 frozen,能不能 hash 呢?默认情况下:可以加 eq=True, frozen=False 但是要显式 unsafe_hash=True,但绝对不建议。因为可变对象一旦被 hash 进集合,你再改它的字段,它在集合里就「迷路」了——hash 还是旧的,但字段已经变了,从此再也找不到。这种坑别去踩,要 hash 就 frozen。

典型场景:复合 key

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass(frozen=True)
class CacheKey:
    user_id: int
    api_path: str


# 模拟一个简单的缓存
cache = {}
key = CacheKey(1001, '/api/profile')
cache[key] = {'name': '两点水', 'avatar': 'xxx.png'}

# 后面查询的时候只需要 key 字段一致,就能命中
print(cache[CacheKey(1001, '/api/profile')])

输出:

{'name': '两点水', 'avatar': 'xxx.png'}

这是 frozen dataclass 一个非常典型的场景——做「复合 key」。

练习 3 / 4frozen=True:不可变 + 可 hash去闯关页做这题 →
教学 05 / 05

四、自动 eq 与 post_init

自动 eq:按字段比较

刚才提过 dataclass 默认会生成 __eq__。这里再细致看一下它的语义:逐字段比较。

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass
class Book:
    title: str
    author: str
    pages: int


b1 = Book('Python 入门', '小明', 300)
b2 = Book('Python 入门', '小明', 300)
b3 = Book('Python 入门', '小明', 500)

print(b1 == b2)
print(b1 == b3)

输出:

True
False

只要所有字段值都一样,两个对象就被判为相等。这跟手写 __eq__ 一个一个比的效果完全一样,但是免费送的。

eq=False 关掉它

如果你不想要自动生成的 __eq__,传 eq=False 就行:

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass(eq=False)
class Employee:
    name: str


a = Employee('两点水')
b = Employee('两点水')
print(a == b)

输出:

False

这时候 == 退化回「比对象身份」,跟 is 一个效果。一般不建议关,关了这个 dataclass 就跟普通 class 没啥区别了。

post_init——构造完之后再算点东西

有没有想过这种需求:我有一个 Order 类,字段是单价 unit_price 和数量 qty,我希望对象一构造好,自动算出一个 total 字段(总价 = 单价 × 数量)。这个 total 不该是用户传进来的,而是「派生出来」的。

dataclass 给我们准备了一个钩子:__post_init__。它会在 __init__ 跑完之后被自动调用,正好用来做「派生计算」。

python到闯关页运行这段 →
from dataclasses import dataclass, field


@dataclass
class Order:
    unit_price: float
    qty: int
    total: float = field(init=False)   # init=False 意味着不进 __init__ 参数列表

    def __post_init__(self):
        self.total = self.unit_price * self.qty


o = Order(unit_price=12.5, qty=8)
print(o)

输出:

Order(unit_price=12.5, qty=8, total=100.0)

这里有两个细节,重点看:

  1. total: float = field(init=False)——init=False 让 total 不出现在 __init__ 的参数里。用户构造 Order 的时候不需要也不该传 total
  2. __post_init__ 是 dataclass 自动调用的,名字必须就是这个,连下划线数量都不能错

在 post_init 里做校验

那如果想在构造时校验字段呢?也是 __post_init__ 的活儿:

python到闯关页运行这段 →
from dataclasses import dataclass


@dataclass
class Employee:
    name: str
    salary: int

    def __post_init__(self):
        if self.salary < 0:
            raise ValueError(f'工资不能是负数,收到:{self.salary}')
        if not self.name:
            raise ValueError('姓名不能为空')


try:
    bad = Employee('两点水', -100)
except ValueError as e:
    print('炸了:', e)

ok = Employee('两点水', 12000)
print(ok)

输出:

炸了: 工资不能是负数,收到:-100
Employee(name='两点水', salary=12000)

不过注意,__post_init__ 里写校验只是「权宜之计」。它的活儿性质偏向「派生计算」,校验逻辑写多了会显得别扭。生产里如果数据来自外部(HTTP 请求、JSON 配置),更推荐用 Pydantic 这类库专门做校验。

小结

这一节内容捋一遍:

  • @dataclass 一行装饰器,自动生成 __init__ / __repr__ / __eq__
  • 类型注解必须写——name: str 是字段,name = '' 不是
  • 可变默认值用 field(default_factory=list),不能直接 = []
  • frozen=True 让 dataclass 不可变,副产品是自动生成 __hash__
  • dataclass + frozen=True + slots=True(3.10+)是「值类型」的黄金三件套
  • __post_init__ 是构造钩子——派生计算、校验都靠它
  • dataclass 不做运行时校验——类型注解只给 mypy/IDE 看,外部脏数据要靠 Pydantic 这类工具

各位以后写真实项目,dataclass 处理「内」——程序内部自己写自己用的数据结构。Pydantic 处理「外」——外部 JSON、HTTP 请求体这些。这条线划清楚了,工具选型就不会乱。

练习 4 / 4自动 __eq__:按字段值比较去闯关页做这题 →

读完了?动手练一遍才算真会。

去闯关模式练习 →