完整讲解 · 18 段教学·配 4 道练习题·预计 80 分钟
本页是本章的通读版,可直接读完全部讲解。想动手写代码、跑判分,去 闯关模式。
写代码这事,一不留神就会陷入「样板代码」的泥潭。各位先别急着反驳,咱们做个小实验:现在请你写一个 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))
e = Employee('两点水', '做鸭事业部', 1001, '2020-03-15', 12000)
print(e)输出:
Employee(name='两点水', dept='做鸭事业部', emp_id=1001, hire_date='2020-03-15', salary=12000)各位数一下,光这么一个普普通通的「数据类」,就花了二十多行。__init__ 写一遍字段,__repr__ 写一遍字段,__eq__ 写一遍字段,__hash__ 又写一遍字段——同一组字段名重复出现了五次。再想象一下你这个类有 15 个字段,那 __init__ 的参数列表就要排成一列火车,每个 self.xxx = xxx 都要复制粘贴,写到第十个就开始想骂人。
这就是所谓的「样板代码」。它没创造任何业务价值,纯粹是 Python 语法要求你必须这么写。写代码的人讨厌它,看代码的人也讨厌它,因为信息密度太低,真正重要的「这个类有哪些字段」被淹没在 self.xxx = xxx 的重复噪声里。
那有没有什么办法,能让我们只声明字段,剩下的活儿让 Python 自己干?
有。Python 3.7 给我们送来了 dataclass。从 3.10 起又给它加了 slots、kw_only 等更现代的开关。再往后还有第三方的 Pydantic,主打「带运行时校验的数据模型」。这一篇,咱们就把「轻量级数据类」这条线从 dataclass 一路捋到 Pydantic,让各位写数据结构的时候,再也不用手指头打结。
把上面那个 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 装饰器定义一个 Point 类:
x: int、y: intPoint(1, 2) 赋给变量 p,并 print(p)输出应该是:
Point(x=1, y=2)(这正是 dataclass 自动生成的 __repr__ 输出。)
记得 from dataclasses import dataclass
字段必须带类型注解:x: int、y: int
完整代码:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(1, 2)
print(p)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 比的是「是不是同一个对象」。这两个事完全两码事,别混。
如果你不想要自动生成的 __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 没啥区别了。
跟普通函数参数一样,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 会直接拍死:
from dataclasses import dataclass
@dataclass
class BadOrder:
qty: int = 1
name: str # 错!没默认值的字段不能跟在有默认值的后面报错:
TypeError: non-default argument 'name' follows default argument那如果默认值是一个「可变对象」呢?比如默认值是个空列表:
from dataclasses import dataclass
@dataclass
class Team:
name: str
members: list = [] # 直接报错报错:
ValueError: mutable default <class 'list'> for field members is not allowed: use default_factoryPython 这次是个负责任的爹,直接把你拦在门外。为啥不让你写?因为如果允许,所有 Team 实例都会共享同一个 members 列表,往一个里 append,所有实例的 members 都会跟着变。这是 Python 一个非常老的坑了,老到 dataclass 设计的时候直接把这条路堵死。
那要怎么写?用 field(default_factory=...)。
field()——字段的高级配置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) # 不参与 == 比较
p1 = Product('手机', 2999.0, internal_id='SECRET-1', cache={'foo': 'bar'})
p2 = Product('手机', 2999.0, internal_id='SECRET-2', cache={'baz': 'qux'})
print(p1)
print(p1 == p2)输出:
Product(name='手机', price=2999.0, discount=0.0, tags=[], cache={'foo': 'bar'})
True挑两个最有用的讲讲:
repr=False:这个字段不会出现在 repr 里。适合放敏感信息(密码、token)或者你不想被打印出来污染日志的内部状态compare=False:这个字段不参与 __eq__ 比较。适合放那种「不影响业务身份」的辅助字段,比如缓存、临时计数器各位看上面 p1 == p2 是 True——虽然 internal_id 和 cache 都不一样,但因为它们一个 repr=False 一个 compare=False,最后比较的时候就被跳过了。
按上面这段代码,比较结果实际为
False。repr=False只隐藏字段的显示,不影响相等比较;两个internal_id不同,仍会导致不相等。只有compare=False才让字段不参与比较。要得到True,还需要给internal_id设置compare=False。
请用 @dataclass 定义一个 Team 类:
name: strmembers: 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=[])(验证两个实例的 members 各自独立,没共享同一个列表。)
注意要 from dataclasses import dataclass, field
field(default_factory=list) 而不是直接 = []
完整代码:
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)frozen=True默认的 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(frozen=True) 定义一个 Point 类(字段 x: int、y: int),让它不可变同时能进 set。
然后:
p1 = Point(1, 2)
p2 = Point(1, 2)
p3 = Point(3, 4)
s = {p1, p2, p3}
print(len(s))因为 p1 和 p2 字段值一样,被 set 当成同一个元素,所以输出应该是:
2这正是 frozen=True 的副产品——自动生成 __hash__。
装饰器要写成 @dataclass(frozen=True)
frozen=True 会自动生成 hash,让对象能进 set
完整代码:
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))请用 @dataclass 定义一个 Book 类:
title: strauthor: strpages: int然后实例化三本书:
b1 = Book('Python 入门', '小明', 300)
b2 = Book('Python 入门', '小明', 300)
b3 = Book('Python 入门', '小明', 500)
print(b1 == b2)
print(b1 == b3)
print(b1 is b2)输出应该是:
True
False
False说明:== 比的是字段值(b1 和 b2 完全相同所以是 True),is 比的是对象身份(b1 和 b2 是两个不同的对象,所以是 False)。
@dataclass 默认会生成 eq,逐字段比较
== 比字段值,is 比对象身份,是两件事
完整代码:
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)
print(b1 is b2)__post_init__——构造完之后再算点东西各位有没有想过这种需求:我有一个 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__ 里写校验只是「权宜之计」。它的活儿性质偏向「派生计算」,校验逻辑写多了会显得别扭。后面咱们讲 Pydantic 的时候,你会看到一个真正为校验而生的工具。
kw_only=True——强制关键字参数(Python 3.10+)各位先看一段代码:
from dataclasses import dataclass
@dataclass
class Employee:
name: str
dept: str
salary: int
e = Employee('两点水', '做鸭事业部', 12000)这种调用方式叫「位置传参」。三个值按字段顺序排队进去,对应得严丝合缝。
但当字段一多,问题就来了:
e = Employee('两点水', '做鸭事业部', 12000)
# 一个月之后,加了字段顺序也调整了:
# Employee(name, salary, dept, hire_date)
# 老代码 Employee('两点水', '做鸭事业部', 12000) 还会运行,但语义完全错了字段顺序一变,所有用位置传参的老代码都会悄无声息地传错值。这种「调用看起来正确但实际错位」的 bug 极难排查。
kw_only=True(Python 3.10+)就是为了治这个病的。一加这个,所有字段都被强制「只能用关键字传参」:
from dataclasses import dataclass
@dataclass(kw_only=True)
class Employee:
name: str
dept: str
salary: int
# 必须写 name= dept= salary=
e = Employee(name='两点水', dept='做鸭事业部', salary=12000)
print(e)
# 想偷懒按位置传?直接报错
try:
bad = Employee('两点水', '做鸭事业部', 12000)
except TypeError as err:
print(err)输出:
Employee(name='两点水', dept='做鸭事业部', salary=12000)
Employee.__init__() takes 1 positional argument but 4 were given调用代码可读性瞬间提升一个档次——你看到 name='水哥',就清清楚楚知道每个值对应的是哪个字段。
kw_only 还能放在单个字段上:
from dataclasses import dataclass, field
@dataclass
class Order:
unit_price: float
qty: int
coupon: str = field(default='', kw_only=True)
o = Order(12.5, 3, coupon='VIP10')
print(o)输出:
Order(unit_price=12.5, qty=3, coupon='VIP10')unit_price 和 qty 还可以位置传,但 coupon 强制关键字。这种局部 kw_only 在「字段一多就容易混的可选项」上特别好使。
注:上面这块是 Python 3.10+ 才有的语法。3.9 之前的 dataclass 只能整个类要么允许位置参数、要么不允许。Python 3.9 就别折腾这个了。
slots=True——让 dataclass 更省内存(Python 3.10+)各位有没有想过,Python 一个普通对象的属性是怎么存的?答案是——存在一个叫 __dict__ 的字典里。每个对象都有自己的 __dict__,里头一份字段名到值的映射。
字典灵活归灵活,但有两个代价:
如果你的程序要创建几百万个 dataclass 实例(比如做数据处理、游戏开发、金融计算),这两个代价会被放大成「真问题」。
slots=True(3.10+)就是给你的解药:
from dataclasses import dataclass
@dataclass(slots=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
print(p)
# 试图给一个不存在的字段赋值,直接炸
try:
p.z = 3.0
except AttributeError as e:
print('炸了:', e)输出:
Point(x=1.0, y=2.0)
炸了: 'Point' object has no attribute 'z'slots=True 干了两件事:
__slots__ = ('x', 'y') 告诉 Python,这个类的实例只允许有 x 和 y 这两个属性,没 __dict__代价是什么?灵活性。你不能再「动态」给实例加属性。但说实话,dataclass 本来就是冲着「字段固定」去的,这个代价完全能接受。
各位记一句话:dataclass + frozen=True + slots=True,是值类型的黄金三件套。
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Coord:
x: float
y: float
z: float
c = Coord(1.0, 2.0, 3.0)
print(c)
print(hash(c))输出:
Coord(x=1.0, y=2.0, z=3.0)
1234567890 # 实际值会变不可变、能 hash、内存省、访问快。这就是现代 Python 写「值对象」的标准姿势。
dataclass 也能继承。但有一个老大难问题各位需要特别注意:带默认值的字段不能跟在不带默认值的字段后面——这条规则在继承场景下特别容易踩坑。
先看一个能跑的例子:
from dataclasses import dataclass
@dataclass
class Animal:
name: str
legs: int = 4
@dataclass
class Dog(Animal):
breed: str = '土狗'
d = Dog('阿黄', 4, '柴犬')
print(d)输出:
Dog(name='阿黄', legs=4, breed='柴犬')子类自动「拼接」父类的字段。Dog.__init__ 的参数就是 name, legs, breed——父类字段在前,子类字段在后。
那什么时候会炸?看这个:
from dataclasses import dataclass
@dataclass
class Animal:
name: str
legs: int = 4 # 有默认值
@dataclass
class Dog(Animal):
breed: str # 没默认值报错:
TypeError: non-default argument 'breed' follows default argument各位想想为啥?因为父类的 legs 已经有默认值了,子类的 breed 又没默认值,拼起来变成 name, legs=4, breed——这就违反了「带默认值的字段不能跟在不带默认值的字段后面」。
这里应当按前面演示的顺序理解:对于普通位置参数,不带默认值的字段不能跟在带默认值的字段后面,所以
breed放在legs=4后面会报错。关键字专用字段有不同规则,下面的kw_only=True正是在利用这个区别。
怎么解?三个办法选一个:
breed: str = ''kw_only=True——这个最优雅:from dataclasses import dataclass
@dataclass(kw_only=True)
class Animal:
name: str
legs: int = 4
@dataclass(kw_only=True)
class Dog(Animal):
breed: str
d = Dog(name='阿黄', breed='柴犬')
print(d)输出:
Dog(name='阿黄', legs=4, breed='柴犬')kw_only=True 把所有字段都改成关键字传参,「位置参数顺序」这个限制就不复存在了,自然也就不会有「带默认值的字段不能在前面」这种烦心事。
各位以后碰到 dataclass 继承,第一反应就该是上 kw_only。
dataclass 是 Python 里最常用的「轻量数据类」工具,但它不是唯一的。还有两个常见兄弟:NamedTuple 和 TypedDict,作用相近但定位不同。简单提一下,让各位心里有数。
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
p = Point(1.0, 2.0)
print(p)
print(p.x, p.y)
print(p[0], p[1]) # 还能像元组一样下标访问
print(tuple(p))输出:
Point(x=1.0, y=2.0)
1.0 2.0
1.0 2.0
(1.0, 2.0)NamedTuple 本质上就是一个「带字段名的元组」。它天然不可变(元组就是不可变的)、自带 __hash__、能解包、能下标。但它没法继承自定义方法的灵活度,也不像 dataclass 那样支持 default_factory、__post_init__。
什么时候用 NamedTuple:你要的就是「带名字的不可变元组」,并且这个东西经常要参与解包或者下标访问。比如坐标、RGB 颜色值、数据库的一行结果。
什么时候用 dataclass:你要的是「类」,可能有方法、可能要派生计算、可能要可变。
from typing import TypedDict
class UserProfile(TypedDict):
name: str
age: int
email: str
u: UserProfile = {'name': '两点水', 'age': 28, 'email': 'liangdianshui@xxx.com'}
print(u)
print(u['name'])输出:
{'name': '两点水', 'age': 28, 'email': 'liangdianshui@xxx.com'}
两点水注意,TypedDict 创建的对象就是字典,不是类实例。u 仍然是 dict,能 u['key'] 访问,不能 u.key。它的作用是「在静态类型层面告诉 mypy 这个字典必须有哪些 key、每个 key 的值是啥类型」。运行时它不做任何校验。
什么时候用 TypedDict:你跟 JSON/外部接口打交道,已经有一个字典了,只想让 mypy 帮你检查 key 和类型是否对得上,不想把字典转成类对象。
捋到这里,各位是不是觉得 dataclass 已经够好用了?确实够好用。但它有一个非常关键的短板——它不做运行时校验。
各位看这段代码:
from dataclasses import dataclass
@dataclass
class Employee:
name: str
age: int
salary: float
# 我故意传错类型
e = Employee(name=123, age='二十八', salary='八千')
print(e)
print(type(e.name), type(e.age), type(e.salary))输出:
Employee(name=123, age='二十八', salary='八千')
<class 'int'> <class 'str'> <class 'str'>各位看到了吗?name 我传了个数字 123,age 传了个字符串 '二十八',salary 传了个字符串 '八千'——dataclass 一声不响地全收下了。它根本不看你的类型注解,那些 name: str、age: int 的注解只是给静态检查工具(mypy、pyright)看的,运行时 dataclass 完全不会去强制验证。
这事儿在「内部代码」里影响不大,因为内部调用一般你自己控制类型。但碰到下面这种场景,问题就大了:
这些数据都来自外部,你没法保证它的字段类型一定正确。靠 dataclass 接,相当于把脏水直接灌进碗里。等你拿 e.salary * 12 想算年薪的时候,发现 salary 是字符串 '八千',再去定位是哪一步出的问题——这种排查成本高得让人想砸键盘。
那有没有什么工具,既能像 dataclass 一样优雅地声明字段,又能在运行时把类型/约束验证好?
有。它就是 Pydantic。
Pydantic 是 Python 生态里最火的「数据校验」库,FastAPI 的核心、LangChain 的接口、各种 SDK 的配置类,背后都是它。它的核心思想很朴素:数据进入边界时,按声明的 schema 严格校验、必要时强制转换;之后程序内部代码就能放心用了。
各位先注意一件事:Pydantic 是第三方库,不在标准库里,需要安装:
pip install pydantic下面所有 Pydantic 代码块,咱们都标记上 skip-ci 注释(让示例校验脚本跳过它),各位自己跑前先装一下。当前主流版本是 Pydantic v2,本文以 v2 为准。
from pydantic import BaseModel
class Employee(BaseModel):
name: str
age: int
salary: float
e = Employee(name='两点水', age=28, salary=12000.0)
print(e)输出:
name='两点水' age=28 salary=12000.0各位是不是觉得这写法跟 dataclass 几乎一样?没错——继承 BaseModel,写带类型注解的字段,剩下的 Pydantic 全包了。它会自动生成 __init__、__repr__、__eq__,还会做一件 dataclass 不做的事——类型校验和强制转换。
看这段:
from pydantic import BaseModel
class Employee(BaseModel):
name: str
age: int
salary: float
# 注意:age 我传的是字符串 '28',salary 传的是字符串 '12000.5'
e = Employee(name='两点水', age='28', salary='12000.5')
print(e)
print(type(e.age), type(e.salary))输出:
name='两点水' age=28 salary=12000.5
<class 'int'> <class 'float'>各位看到了吗?我传的 age='28' 是字符串,但 Pydantic 帮我转成了 int。salary='12000.5' 也被转成了 float。这就是 Pydantic 的「智能转换」——只要类型能合理转换,它就帮你转。
那如果传的是真的转不过去的呢?
from pydantic import BaseModel, ValidationError
class Employee(BaseModel):
name: str
age: int
salary: float
try:
e = Employee(name='两点水', age='abc', salary='not a number')
except ValidationError as err:
print(err)输出(大致样子):
2 validation errors for Employee
age
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='abc', input_type=str]
salary
Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='not a number', input_type=str]漂亮吧?不仅给你抛了 ValidationError,还告诉你哪些字段、为什么错、收到的是啥——这是 dataclass 完全做不到的。
model_validate 和 model_validate_json这才是 Pydantic 的杀手锏。各位想象一下,你写了个 HTTP 接口,前端 POST 过来一段 JSON,你需要:
dataclass 这条路你得自己写一堆校验代码。Pydantic 一行搞定:
from pydantic import BaseModel
class Employee(BaseModel):
name: str
age: int
salary: float
# 从一段 JSON 字符串解析
json_str = '{"name": "两点水", "age": 28, "salary": 12000}'
e = Employee.model_validate_json(json_str)
print(e)
# 从一个字典解析
data = {'name': '小明', 'age': 30, 'salary': 15000}
e2 = Employee.model_validate(data)
print(e2)输出:
name='两点水' age=28 salary=12000.0
name='小明' age=30 salary=15000.0model_validate(d) 是从字典构造模型,model_validate_json(s) 是从 JSON 字符串构造。两个方法都会走完整的校验流程,缺字段、错类型、约束不满足都会抛 ValidationError。
反过来,把模型转回字典/JSON 也很方便:
from pydantic import BaseModel
class Employee(BaseModel):
name: str
age: int
salary: float
e = Employee(name='两点水', age=28, salary=12000.0)
print(e.model_dump()) # 转字典
print(e.model_dump_json()) # 转 JSON 字符串输出:
{'name': '两点水', 'age': 28, 'salary': 12000.0}
{"name":"两点水","age":28,"salary":12000.0}「字典 ↔ 模型 ↔ JSON」这三角转换是 Pydantic 最爽的部分,处理外部数据的时候简直可以躺平。
Field(...) 的力量类型校验只是入门,真正强大的是「约束校验」。比如各位常常需要:
0 <= age <= 120、salary >= 01 <= len(name) <= 50Pydantic 用 Field(...) 表达这些约束:
from pydantic import BaseModel, Field, ValidationError
class Employee(BaseModel):
name: str = Field(min_length=1, max_length=50)
age: int = Field(ge=18, le=65) # ge = 大于等于, le = 小于等于
salary: float = Field(gt=0) # gt = 大于
rating: int = Field(ge=0, le=100, default=60)
# 正常的
e = Employee(name='两点水', age=28, salary=12000.0)
print(e)
# 年龄超界
try:
bad = Employee(name='小明', age=10, salary=5000.0)
except ValidationError as err:
print(err)输出(大致样子):
name='两点水' age=28 salary=12000.0 rating=60
1 validation error for Employee
age
Input should be greater than or equal to 18 [type=greater_than_equal, input_value=10, input_type=int]Field(...) 支持的约束相当多,各位常用的就这几个:
gt、ge、lt、le、multiple_ofmin_length、max_length、pattern(正则)min_length、max_lengthdefault=...、default_factory=...alias='另一个名字'(接受外部 JSON 的 key 跟内部字段名不一样的情况)alias 这个特别常用,举个例子:
from pydantic import BaseModel, Field
class Employee(BaseModel):
name: str
employee_id: int = Field(alias='empId') # 外部 JSON 用 empId,内部用 employee_id
e = Employee.model_validate({'name': '两点水', 'empId': 1001})
print(e)
print(e.employee_id)输出:
name='两点水' employee_id=1001
1001外部 JSON 用驼峰式 empId,内部 Python 用蛇形 employee_id,两边各自自然,靠 alias 把它们连起来。
pydantic.types光约束数值和字符串还不够,Pydantic 还内置了一些「语义类型」:
from pydantic import BaseModel, EmailStr, HttpUrl, ValidationError
class User(BaseModel):
name: str
email: EmailStr
homepage: HttpUrl
u = User(name='两点水', email='liangdianshui@example.com', homepage='https://example.com')
print(u)
try:
bad = User(name='X', email='not-an-email', homepage='not-a-url')
except ValidationError as err:
print(err)输出(大致样子):
name='两点水' email='liangdianshui@example.com' homepage=Url('https://example.com/')
2 validation errors for User
email
value is not a valid email address: ...
homepage
Input should be a valid URL, ...EmailStr 需要额外装一下 email-validator:pip install pydantic[email]。HttpUrl 是内置的,直接能用。
这种「语义类型」 dataclass 是没法直接给的——你只能用 str 然后手写正则校验。Pydantic 把这些常见场景都打包进库,省心非常多。
Pydantic 不光是个校验库,它围绕「数据建模」长出了一整套生态:
pydantic-settings:专门给「配置类」用的。从环境变量、.env 文件、命令行参数加载配置,自动校验。FastAPI 应用通常用它管 DATABASE_URL、SECRET_KEY 这些。安装:pip install pydantic-settingsmypy + Pydantic 插件:让 mypy 能正确推导 Pydantic 模型的字段类型,配合 IDE 提示极其爽BaseModel 声明,自动校验、自动生成 OpenAPI 文档各位先不用一下子学完,知道这些工具的存在,等碰到具体场景的时候顺藤摸瓜就好。
这一段是各位最关心的:「这么多工具,我到底该用哪个」?我用三行话总结:
dataclass:标准库自带、零依赖、轻量;用于「程序内部」自己写自己用的数据结构。性能好、不做运行时校验Pydantic:第三方、要安装;用于「数据边界」——外部 JSON、HTTP 请求体、配置文件这些。带类型校验、带约束、带智能转换attrs:dataclass 的精神前辈,第三方库,比 dataclass 早出现,功能也更全(比如 validator、converter)。dataclass 出来之后,attrs 的需求场景被压缩了,但「需要复杂校验但不想引入 Pydantic 的运行时开销」时仍然是个好选择更具体一点的判断:
| 场景 | 推荐 |
|---|---|
| 函数返回值的「结构化结果」 | dataclass |
| 内存里大量小对象(坐标、缓存 key) | dataclass + frozen + slots |
| 接收 HTTP 请求体 / 解析外部 JSON | Pydantic |
| 应用配置(读环境变量) | pydantic-settings |
| 写 FastAPI 接口 | Pydantic(FastAPI 强制) |
| 想要复杂 validator 但不想引 Pydantic | attrs |
各位记住:dataclass 处理「内」,Pydantic 处理「外」。这条线划清楚了,工具选型就不会乱。
Order + OrderRequest 模拟一个下单接口理论够多了,咱们来一段贴近真实场景的代码:模拟一个「下单」接口。
需求是这样:
Order 对象,自动算出总价、生成订单号我们让 Pydantic 接外部 JSON、做校验,让 dataclass 表达内部业务对象。两者各司其职。
Order(dataclass)import uuid
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Order:
order_id: str
product_id: int
qty: int
unit_price: float
address: str
user_email: str
total: float = field(init=False)
def __post_init__(self):
# frozen 类不能直接 self.total = ...,要用 object.__setattr__ 绕过
object.__setattr__(self, 'total', self.qty * self.unit_price)
o = Order(
order_id=str(uuid.uuid4()),
product_id=1001,
qty=3,
unit_price=12.5,
address='杭州市西湖区某某路 1 号',
user_email='liangdianshui@example.com',
)
print(o)输出(order_id 每次不同):
Order(order_id='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', product_id=1001, qty=3, unit_price=12.5, address='杭州市西湖区某某路 1 号', user_email='liangdianshui@example.com', total=37.5)各位看 __post_init__ 里那一行 object.__setattr__(self, 'total', ...)——这是 frozen dataclass 写派生字段的固定姿势。因为 frozen 之后 self.xx = yy 会被拒绝,但派生字段又必须在构造完成后写一次,所以只能绕过 __setattr__ 的封锁。
OrderRequest(Pydantic)from pydantic import BaseModel, Field, EmailStr
class OrderRequest(BaseModel):
product_id: int = Field(gt=0)
qty: int = Field(gt=0, le=100)
unit_price: float = Field(gt=0)
address: str = Field(min_length=1, max_length=200)
user_email: EmailStr
# 模拟前端发来的 JSON
payload = '''
{
"product_id": 1001,
"qty": 3,
"unit_price": 12.5,
"address": "杭州市西湖区某某路 1 号",
"user_email": "liangdianshui@example.com"
}
'''
req = OrderRequest.model_validate_json(payload)
print(req)输出:
product_id=1001 qty=3 unit_price=12.5 address='杭州市西湖区某某路 1 号' user_email='liangdianshui@example.com'如果前端发了一个有问题的 payload(比如数量为负),Pydantic 会立刻告诉你哪里错了:
from pydantic import BaseModel, Field, EmailStr, ValidationError
class OrderRequest(BaseModel):
product_id: int = Field(gt=0)
qty: int = Field(gt=0, le=100)
unit_price: float = Field(gt=0)
address: str = Field(min_length=1, max_length=200)
user_email: EmailStr
bad_payload = '''
{
"product_id": 1001,
"qty": -1,
"unit_price": 12.5,
"address": "",
"user_email": "not-an-email"
}
'''
try:
OrderRequest.model_validate_json(bad_payload)
except ValidationError as err:
print(err)输出(大致样子):
3 validation errors for OrderRequest
qty
Input should be greater than 0 ...
address
String should have at least 1 character ...
user_email
value is not a valid email address: ...三个字段同时报错,每个错误位置和原因都列得清清楚楚。这就是 Pydantic 在边界上拦截脏数据的样子。
import uuid
from dataclasses import dataclass, field
from pydantic import BaseModel, Field, EmailStr
@dataclass(frozen=True)
class Order:
order_id: str
product_id: int
qty: int
unit_price: float
address: str
user_email: str
total: float = field(init=False)
def __post_init__(self):
object.__setattr__(self, 'total', self.qty * self.unit_price)
class OrderRequest(BaseModel):
product_id: int = Field(gt=0)
qty: int = Field(gt=0, le=100)
unit_price: float = Field(gt=0)
address: str = Field(min_length=1, max_length=200)
user_email: EmailStr
def create_order(payload_json: str) -> Order:
"""边界层:拿到 JSON 就先用 Pydantic 校验,过了就转成 Order 业务对象"""
req = OrderRequest.model_validate_json(payload_json)
return Order(
order_id=str(uuid.uuid4()),
product_id=req.product_id,
qty=req.qty,
unit_price=req.unit_price,
address=req.address,
user_email=req.user_email,
)
payload = '''
{
"product_id": 1001,
"qty": 3,
"unit_price": 12.5,
"address": "杭州市西湖区某某路 1 号",
"user_email": "liangdianshui@example.com"
}
'''
order = create_order(payload)
print(order)
print(f'订单总价:{order.total}')各位看这套架构:
OrderRequest(Pydantic)守在边界。所有从外部进来的脏数据都得先过它这一关Order(dataclass,frozen)在内部。一旦构造出来就不可变,业务代码可以放心地传递、放进 set、做 dict key这正是「dataclass 处理内、Pydantic 处理外」的实际写法。各位以后写真实项目,无论是 FastAPI 接口、消息队列消费者,还是命令行工具,都可以套这个模式:边界用 Pydantic 校验、内部用 dataclass 表达。
写到这里,理论和实战都齐活了,最后再给各位提醒几个特别容易踩的坑:
各位还记得吧?name: str 会被识别成字段,name = '' 不会。
from dataclasses import dataclass
@dataclass
class Bad:
a = 1 # 没注解,不是字段
b: int = 2
print(Bad())
# 你会发现 a 不在 repr 里,因为它根本不被 dataclass 识别输出:
Bad(b=2)default_factory不能写 tags: list = [],要写 tags: list = field(default_factory=list)。前者会被 Python 直接拍死。
object.__setattr__如上面 Order 的例子。
很多老教程是 v1 的。常见对照:
| v1 | v2 |
|---|---|
obj.dict() | obj.model_dump() |
obj.json() | obj.model_dump_json() |
Model.parse_obj(d) | Model.model_validate(d) |
Model.parse_raw(s) | Model.model_validate_json(s) |
validator 装饰器 | field_validator 装饰器 |
各位看到 .dict()、.json() 这种短名字的 API,多半是老 v1 的东西。当下应该用 v2 的 model_* 系列。
各位千万别拿 dataclass 当 Pydantic 用。前面说过了,dataclass 不做运行时校验。它的类型注解只给 mypy/IDE 看。如果你需要校验,要么用 __post_init__ 自己写,要么直接换 Pydantic。
回头看看咱们这一篇都聊了啥:
@dataclass,自动生成 __init__ / __repr__ / __eq__,告别样板代码field() 是字段的瑞士军刀——default_factory 解决可变默认值、init=False 配合 __post_init__ 写派生字段、repr=False 和 compare=False 控制 repr 和比较行为frozen=True 让 dataclass 不可变——副产品是自动生成 __hash__,能放进 set、能当 dict key,做「值类型」必备__post_init__ 是构造钩子——派生计算、校验都靠它(但校验只是权宜之计)kw_only=True 和 slots=True——前者把字段改成强制关键字传参,对继承场景特别友好;后者用 __slots__ 替代 __dict__,省内存、提性能BaseModel 一继承,类型校验、约束校验、智能转换、JSON 互转全到位Field(...) 表达约束——gt、ge、lt、le、min_length、max_length、pattern 这些参数把字段约束写得清清楚楚EmailStr、HttpUrl 这些语义类型让常见校验场景一行搞定OrderRequest 校验,内部用 Order 表达业务对象,两者各自发光这一篇内容比较密。各位不用一次都消化掉——dataclass 那部分可以现在就用起来,Pydantic 那部分等真的碰到「外部数据进来要校验」的场景,再回头翻一下就够。
下一篇咱们聊 Python 里另一个又常见又容易写错的主题——上下文管理器(with 语句)。从 with open(...) as f 这个最常见的姿势出发,把 __enter__ / __exit__ 协议、contextlib、异步上下文管理器一路捋清楚。咱们下篇见。
我们用 dataclass 保存一份学习记录,再加一个复习备注列表。列表应当由每条记录单独拥有:
from dataclasses import dataclass, field
@dataclass
class StudyRecord:
subject: str
minutes: int
notes: list[str] = field(default_factory=list)
first = StudyRecord('Python', 15)
second = StudyRecord('读书', 10)
first.notes.append('已复习')
print(first.notes)
print(second.notes)输出:
['已复习']
[]default_factory=list 表示需要默认值时调用 list 创建一份新列表。自己再给第二条记录增加备注,确认不会改变第一条。这可以和函数章节的默认参数共享问题放在一起比较,但两处构建默认值的规则不相同,不能把语法直接照搬。
另一个要分清楚的问题是:有字段注解、自动生成初始化方法,不等于所有输入都自动经过业务校验。当前的普通 dataclass 不会仅因为写了 minutes: int 就拒绝字符串。需要校验时明确实现;使用 Pydantic 时,再按其版本、模型配置与转换规则理解实际行为。
本例只依赖标准库,可以先运行。第三方库示例,在准备对应本地环境后再做。先说清楚希望省掉哪些样板代码、哪些输入仍需校验,再选择数据模型。
读完了?动手练一遍才算真会。
去闯关模式练习 →