dataclass 与 Pydantic
写代码这事,一不留神就会陷入「样板代码」的泥潭。各位先别急着反驳,咱们做个小实验:现在请你写一个 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)
输出:
各位数一下,光这么一个普普通通的「数据类」,就花了二十多行。__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,让各位写数据结构的时候,再也不用手指头打结。
第一个 dataclass,长什么样¶
把上面那个 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)
输出:
各位看,二十多行的代码,缩成了不到十行。@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)
输出:
看到没?c 没出现在 repr 里,因为它根本不是一个字段,只是个挂在类上的常量。
比较两个 dataclass 对象¶
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)
输出:
各位注意第三行——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)
输出:
这时候 == 退化回「比对象身份」,跟 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)
输出:
各位有没有注意到,跟函数参数一个道理——「带默认值的字段」必须放在「不带默认值的字段」后面。下面这种写法,Python 会直接拍死:
from dataclasses import dataclass
@dataclass
class BadOrder:
qty: int = 1
name: str # 错!没默认值的字段不能跟在有默认值的后面
报错:
那如果默认值是一个「可变对象」呢?比如默认值是个空列表:
报错:
Python 这次是个负责任的爹,直接把你拦在门外。为啥不让你写?因为如果允许,所有 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)
输出:
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)
输出:
各位看最后一个 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)
输出:
挑两个最有用的讲讲:
repr=False:这个字段不会出现在repr里。适合放敏感信息(密码、token)或者你不想被打印出来污染日志的内部状态compare=False:这个字段不参与__eq__比较。适合放那种「不影响业务身份」的辅助字段,比如缓存、临时计数器
各位看上面 p1 == p2 是 True——虽然 internal_id 和 cache 都不一样,但因为它们一个 repr=False 一个 compare=False,最后比较的时候就被跳过了。
不可变 dataclass——frozen=True¶
默认的 dataclass 实例是「可变」的,你随时能给字段重新赋值:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(1, 2)
p.x = 100
print(p)
输出:
但有时候你希望对象创建之后就不再变了——比如「坐标点」、「枚举值」、「配置项」这种概念上就该是只读的东西。这时候就该 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)
输出:
赋值就抛 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)])
输出:
各位看,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')])
输出:
这是 frozen dataclass 一个非常典型的场景——做「复合 key」。
__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)
输出:
这里有两个细节,各位重点看:
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)
输出:
不过各位注意,__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)
输出:
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)
输出:
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))
输出:
不可变、能 hash、内存省、访问快。这就是现代 Python 写「值对象」的标准姿势。
dataclass 跟继承¶
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.__init__ 的参数就是 name, legs, breed——父类字段在前,子类字段在后。
那什么时候会炸?看这个:
from dataclasses import dataclass
@dataclass
class Animal:
name: str
legs: int = 4 # 有默认值
@dataclass
class Dog(Animal):
breed: str # 没默认值
报错:
各位想想为啥?因为父类的 legs 已经有默认值了,子类的 breed 又没默认值,拼起来变成 name, legs=4, breed——这就违反了「带默认值的字段不能跟在不带默认值的字段后面」。
怎么解?三个办法选一个:
- 给子类字段也加默认值:
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)
输出:
kw_only=True 把所有字段都改成关键字传参,「位置参数顺序」这个限制就不复存在了,自然也就不会有「带默认值的字段不能在前面」这种烦心事。
各位以后碰到 dataclass 继承,第一反应就该是上 kw_only。
不止 dataclass——还有 NamedTuple 和 TypedDict¶
dataclass 是 Python 里最常用的「轻量数据类」工具,但它不是唯一的。还有两个常见兄弟:NamedTuple 和 TypedDict,作用相近但定位不同。简单提一下,让各位心里有数。
NamedTuple——给元组加上名字¶
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))
输出:
NamedTuple 本质上就是一个「带字段名的元组」。它天然不可变(元组就是不可变的)、自带 __hash__、能解包、能下标。但它没法继承自定义方法的灵活度,也不像 dataclass 那样支持 default_factory、__post_init__。
什么时候用 NamedTuple:你要的就是「带名字的不可变元组」,并且这个东西经常要参与解包或者下标访问。比如坐标、RGB 颜色值、数据库的一行结果。
什么时候用 dataclass:你要的是「类」,可能有方法、可能要派生计算、可能要可变。
TypedDict——给字典加上类型注解¶
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'])
输出:
注意,TypedDict 创建的对象就是字典,不是类实例。u 仍然是 dict,能 u['key'] 访问,不能 u.key。它的作用是「在静态类型层面告诉 mypy 这个字典必须有哪些 key、每个 key 的值是啥类型」。运行时它不做任何校验。
什么时候用 TypedDict:你跟 JSON/外部接口打交道,已经有一个字典了,只想让 mypy 帮你检查 key 和类型是否对得上,不想把字典转成类对象。
dataclass 的局限——它不做运行时校验¶
捋到这里,各位是不是觉得 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))
输出:
各位看到了吗?name 我传了个数字 123,age 传了个字符串 '二十八',salary 传了个字符串 '八千'——dataclass 一声不响地全收下了。它根本不看你的类型注解,那些 name: str、age: int 的注解只是给静态检查工具(mypy、pyright)看的,运行时 dataclass 完全不会去强制验证。
这事儿在「内部代码」里影响不大,因为内部调用一般你自己控制类型。但碰到下面这种场景,问题就大了:
- 一个 HTTP 接口收到客户端发来的 JSON
- 一个配置文件里的 YAML/TOML 被解析成字典
- 一个消息队列里的消息被反序列化
这些数据都来自外部,你没法保证它的字段类型一定正确。靠 dataclass 接,相当于把脏水直接灌进碗里。等你拿 e.salary * 12 想算年薪的时候,发现 salary 是字符串 '八千',再去定位是哪一步出的问题——这种排查成本高得让人想砸键盘。
那有没有什么工具,既能像 dataclass 一样优雅地声明字段,又能在运行时把类型/约束验证好?
有。它就是 Pydantic。
Pydantic 入场——带运行时校验的数据模型¶
Pydantic 是 Python 生态里最火的「数据校验」库,FastAPI 的核心、LangChain 的接口、各种 SDK 的配置类,背后都是它。它的核心思想很朴素:数据进入边界时,按声明的 schema 严格校验、必要时强制转换;之后程序内部代码就能放心用了。
各位先注意一件事:Pydantic 是第三方库,不在标准库里,需要安装:
下面所有 Pydantic 代码块,咱们都标记上 skip-ci 注释(让示例校验脚本跳过它),各位自己跑前先装一下。当前主流版本是 Pydantic v2,本文以 v2 为准。
第一个 BaseModel¶
from pydantic import BaseModel
class Employee(BaseModel):
name: str
age: int
salary: float
e = Employee(name='两点水', age=28, salary=12000.0)
print(e)
输出:
各位是不是觉得这写法跟 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))
输出:
各位看到了吗?我传的 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 完全做不到的。
从字典/JSON 构造模型——model_validate 和 model_validate_json¶
这才是 Pydantic 的杀手锏。各位想象一下,你写了个 HTTP 接口,前端 POST 过来一段 JSON,你需要:
- 把 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)
输出:
model_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 字符串
输出:
「字典 ↔ 模型 ↔ JSON」这三角转换是 Pydantic 最爽的部分,处理外部数据的时候简直可以躺平。
字段约束——Field(...) 的力量¶
类型校验只是入门,真正强大的是「约束校验」。比如各位常常需要:
- 数值在某个范围内:
0 <= age <= 120、salary >= 0 - 字符串长度有限制:
1 <= len(name) <= 50 - 字符串符合某个正则
- 是合法的邮箱、URL、UUID
Pydantic 用 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_of - 字符串类:
min_length、max_length、pattern(正则) - 列表类:
min_length、max_length - 默认值:
default=...、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)
输出:
外部 JSON 用驼峰式 empId,内部 Python 用蛇形 employee_id,两边各自自然,靠 alias 把它们连起来。
邮箱、URL 这些复杂类型——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 不光是个校验库,它围绕「数据建模」长出了一整套生态:
pydantic-settings:专门给「配置类」用的。从环境变量、.env文件、命令行参数加载配置,自动校验。FastAPI 应用通常用它管DATABASE_URL、SECRET_KEY这些。安装:pip install pydantic-settingsmypy+ Pydantic 插件:让mypy能正确推导 Pydantic 模型的字段类型,配合 IDE 提示极其爽- FastAPI:核心就是 Pydantic。请求体用
BaseModel声明,自动校验、自动生成 OpenAPI 文档 - LangChain、Anthropic SDK、OpenAI SDK:内部到处都是 Pydantic 模型——它已经成了 Python 数据建模的事实标准
各位先不用一下子学完,知道这些工具的存在,等碰到具体场景的时候顺藤摸瓜就好。
dataclass vs Pydantic vs attrs——三选一指南¶
这一段是各位最关心的:「这么多工具,我到底该用哪个」?我用三行话总结:
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 模拟一个下单接口¶
理论够多了,咱们来一段贴近真实场景的代码:模拟一个「下单」接口。
需求是这样:
- 客户端发来 JSON,包含商品 ID、数量、收货地址、用户邮箱
- 服务端校验:数量必须大于 0,邮箱必须合法,地址不能为空
- 校验通过之后,转成内部的
Order对象,自动算出总价、生成订单号
我们让 Pydantic 接外部 JSON、做校验,让 dataclass 表达内部业务对象。两者各司其职。
1. 先定义内部 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__ 的封锁。
2. 再定义外部 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 在边界上拦截脏数据的样子。
3. 把外部请求转成内部 Order¶
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 表达。
一点小提醒——dataclass 跟 Pydantic 的常见踩坑¶
写到这里,理论和实战都齐活了,最后再给各位提醒几个特别容易踩的坑:
坑一:dataclass 字段必须有类型注解¶
各位还记得吧?name: str 会被识别成字段,name = '' 不会。
from dataclasses import dataclass
@dataclass
class Bad:
a = 1 # 没注解,不是字段
b: int = 2
print(Bad())
# 你会发现 a 不在 repr 里,因为它根本不被 dataclass 识别
输出:
坑二:可变默认值必须用 default_factory¶
不能写 tags: list = [],要写 tags: list = field(default_factory=list)。前者会被 Python 直接拍死。
坑三:frozen 之后写不了派生字段,要用 object.__setattr__¶
如上面 Order 的例子。
坑四:Pydantic v1 和 v2 的 API 名字不一样¶
很多老教程是 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 的类型注解只是「文档」¶
各位千万别拿 dataclass 当 Pydantic 用。前面说过了,dataclass 不做运行时校验。它的类型注解只给 mypy/IDE 看。如果你需要校验,要么用 __post_init__ 自己写,要么直接换 Pydantic。
小结¶
回头看看咱们这一篇都聊了啥:
- dataclass 是 Python 写「数据类」的标准答案——一行
@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__是构造钩子——派生计算、校验都靠它(但校验只是权宜之计)- Python 3.10+ 加了
kw_only=True和slots=True——前者把字段改成强制关键字传参,对继承场景特别友好;后者用__slots__替代__dict__,省内存、提性能 - NamedTuple 是「带名字的元组」、TypedDict 是「带类型的字典」——dataclass 之外的两个轻量级选项,各有各的甜蜜点
- dataclass 不做运行时校验——这是它最大的局限。外部脏数据进来直接照单全收
- Pydantic 补上了这块——
BaseModel一继承,类型校验、约束校验、智能转换、JSON 互转全到位 - Pydantic 用
Field(...)表达约束——gt、ge、lt、le、min_length、max_length、pattern这些参数把字段约束写得清清楚楚 EmailStr、HttpUrl这些语义类型让常见校验场景一行搞定- 「dataclass 处理内、Pydantic 处理外」——这是各位以后做架构选型最重要的一句话
- 小实战展示了真实模式——边界用
OrderRequest校验,内部用Order表达业务对象,两者各自发光
这一篇内容比较密。各位不用一次都消化掉——dataclass 那部分可以现在就用起来,Pydantic 那部分等真的碰到「外部数据进来要校验」的场景,再回头翻一下就够。
下一篇咱们聊 Python 里另一个又常见又容易写错的主题——上下文管理器(with 语句)。从 with open(...) as f 这个最常见的姿势出发,把 __enter__ / __exit__ 协议、contextlib、异步上下文管理器一路捋清楚。咱们下篇见。