完整讲解 · 5 段教学·配 4 道练习题·预计 35 分钟
本页是本章的通读版,可直接读完全部讲解。想动手写代码、跑判分,去 闯关模式。
写代码这事,一不留神就会陷入「样板代码」的泥潭。先做个小实验:现在请你写一个 Employee 类,要求是这样的——存员工的姓名、部门、工号、入职日期、月薪,要能正常打印(不能是 <__main__.Employee object at 0x...> 这种鬼东西),要能比较两个员工对象是否相等,最好还能拿出来做字典的 key。
听起来不难是吧?大概长这样:
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=...) 处理可变默认值__eq__ 自动按字段值比较学完之后,再写数据类基本上就是「贴一个装饰器、列一下字段」这种轻松活儿。
把上面那个 Employee 类,用 dataclass 重写一遍:
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 = '默认值' 是「类属性」,两者作用截然不同。
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 默认会生成 __eq__,所以两个字段值完全相同的对象,会被判为相等:
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 比的是「是不是同一个对象」。这两个事完全两码事,别混。
跟普通函数参数一样,dataclass 字段也能有默认值:
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 会直接拍死:
@dataclass
class BadOrder:
qty: int = 1
name: str # 错!没默认值的字段不能跟在有默认值的后面报错:
TypeError: non-default argument 'name' follows default argument
那如果默认值是一个「可变对象」呢?比如默认值是个空列表:
@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() 是 dataclasses 模块里另一个主角。它专门用来给字段做更细的配置。最常见的用法就是「可变默认值」:
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 还能接任何「无参数可调用对象」。比如:
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() 还有几个常用参数:
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__ 比较。适合放那种「不影响业务身份」的辅助字段,比如缓存、临时计数器默认的 dataclass 实例是「可变」的,你随时能给字段重新赋值:
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 上场:
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=True 还有一个重要的副作用:Python 会顺便给你生成 __hash__,让这个对象能放进 set、能当 dict 的 key。
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 呢?默认情况下:可以加 eq=True, frozen=False 但是要显式 unsafe_hash=True,但绝对不建议。因为可变对象一旦被 hash 进集合,你再改它的字段,它在集合里就「迷路」了——hash 还是旧的,但字段已经变了,从此再也找不到。这种坑别去踩,要 hash 就 frozen。
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」。
刚才提过 dataclass 默认会生成 __eq__。这里再细致看一下它的语义:逐字段比较。
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__,传 eq=False 就行:
from dataclasses import dataclass
@dataclass(eq=False)
class Employee:
name: str
a = Employee('两点水')
b = Employee('两点水')
print(a == b)输出:
False
这时候 == 退化回「比对象身份」,跟 is 一个效果。一般不建议关,关了这个 dataclass 就跟普通 class 没啥区别了。
有没有想过这种需求:我有一个 Order 类,字段是单价 unit_price 和数量 qty,我希望对象一构造好,自动算出一个 total 字段(总价 = 单价 × 数量)。这个 total 不该是用户传进来的,而是「派生出来」的。
dataclass 给我们准备了一个钩子:__post_init__。它会在 __init__ 跑完之后被自动调用,正好用来做「派生计算」。
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)
这里有两个细节,重点看:
total: float = field(init=False)——init=False 让 total 不出现在 __init__ 的参数里。用户构造 Order 的时候不需要也不该传 total__post_init__ 是 dataclass 自动调用的,名字必须就是这个,连下划线数量都不能错那如果想在构造时校验字段呢?也是 __post_init__ 的活儿:
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____post_init__ 是构造钩子——派生计算、校验都靠它各位以后写真实项目,dataclass 处理「内」——程序内部自己写自己用的数据结构。Pydantic 处理「外」——外部 JSON、HTTP 请求体这些。这条线划清楚了,工具选型就不会乱。
读完了?动手练一遍才算真会。
去闯关模式练习 →