跳转至

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)

输出:

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 起又给它加了 slotskw_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)

输出:

Employee(name='两点水', dept='做鸭事业部', emp_id=1001, hire_date='2020-03-15', salary=12000)

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

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

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

那「类型注解」是不是必须的?是的。这是 dataclass 识别字段的依据。你如果只写 name = '' 而不写 name: strdataclass 就认不出来——它会把 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 对象

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 bFalse== 比的是「字段值是否相等」,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_factory

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)

输出:

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 == p2True——虽然 internal_idcache 都不一样,但因为它们一个 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)

输出:

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
原点

各位看,p1p2 字段值一样,被 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」。

__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)

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

  1. total: float = field(init=False)——init=Falsetotal 不出现在 __init__ 的参数里。用户构造 Order 的时候不需要也不该传 total
  2. __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_priceqty 还可以位置传,但 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 干了两件事:

  1. __slots__ = ('x', 'y') 告诉 Python,这个类的实例只允许有 xy 这两个属性,没 __dict__
  2. 实例不再走字典存储,内存占用大幅下降,属性访问速度也略快

代价是什么?灵活性。你不能再「动态」给实例加属性。但说实话,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 跟继承

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——这就违反了「带默认值的字段不能跟在不带默认值的字段后面」。

怎么解?三个办法选一个:

  1. 给子类字段也加默认值:breed: str = ''
  2. 给父类的有默认值字段拿掉默认值(但通常没法这么干,会破坏现有调用)
  3. 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——还有 NamedTuple 和 TypedDict

dataclass 是 Python 里最常用的「轻量数据类」工具,但它不是唯一的。还有两个常见兄弟:NamedTupleTypedDict,作用相近但定位不同。简单提一下,让各位心里有数。

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))

输出:

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:你要的是「类」,可能有方法、可能要派生计算、可能要可变。

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'])

输出:

{'name': '两点水', 'age': 28, 'email': 'liangdianshui@xxx.com'}
两点水

注意,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))

输出:

Employee(name=123, age='二十八', salary='八千')
<class 'int'> <class 'str'> <class 'str'>

各位看到了吗?name 我传了个数字 123age 传了个字符串 '二十八'salary 传了个字符串 '八千'——dataclass 一声不响地全收下了。它根本不看你的类型注解,那些 name: strage: int 的注解只是给静态检查工具(mypy、pyright)看的,运行时 dataclass 完全不会去强制验证。

这事儿在「内部代码」里影响不大,因为内部调用一般你自己控制类型。但碰到下面这种场景,问题就大了:

  • 一个 HTTP 接口收到客户端发来的 JSON
  • 一个配置文件里的 YAML/TOML 被解析成字典
  • 一个消息队列里的消息被反序列化

这些数据都来自外部,你没法保证它的字段类型一定正确。靠 dataclass 接,相当于把脏水直接灌进碗里。等你拿 e.salary * 12 想算年薪的时候,发现 salary 是字符串 '八千',再去定位是哪一步出的问题——这种排查成本高得让人想砸键盘。

那有没有什么工具,既能像 dataclass 一样优雅地声明字段,又能在运行时把类型/约束验证好

有。它就是 Pydantic。

Pydantic 入场——带运行时校验的数据模型

Pydantic 是 Python 生态里最火的「数据校验」库,FastAPI 的核心、LangChain 的接口、各种 SDK 的配置类,背后都是它。它的核心思想很朴素:数据进入边界时,按声明的 schema 严格校验、必要时强制转换;之后程序内部代码就能放心用了

各位先注意一件事:Pydantic 是第三方库,不在标准库里,需要安装:

pip install 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)

输出:

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 帮我转成了 intsalary='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_validatemodel_validate_json

这才是 Pydantic 的杀手锏。各位想象一下,你写了个 HTTP 接口,前端 POST 过来一段 JSON,你需要:

  1. 把 JSON 解析成字典
  2. 校验里面的字段类型/必填项
  3. 转成你的内部数据类型

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.0

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 字符串

输出:

{'name': '两点水', 'age': 28, 'salary': 12000.0}
{"name":"两点水","age":28,"salary":12000.0}

「字典 ↔ 模型 ↔ JSON」这三角转换是 Pydantic 最爽的部分,处理外部数据的时候简直可以躺平。

字段约束——Field(...) 的力量

类型校验只是入门,真正强大的是「约束校验」。比如各位常常需要:

  • 数值在某个范围内:0 <= age <= 120salary >= 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(...) 支持的约束相当多,各位常用的就这几个:

  • 数值类:gtgeltlemultiple_of
  • 字符串类:min_lengthmax_lengthpattern(正则)
  • 列表类:min_lengthmax_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)

输出:

name='两点水' employee_id=1001
1001

外部 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-validatorpip install pydantic[email]HttpUrl 是内置的,直接能用。

这种「语义类型」 dataclass 是没法直接给的——你只能用 str 然后手写正则校验。Pydantic 把这些常见场景都打包进库,省心非常多。

Pydantic 生态一瞥

Pydantic 不光是个校验库,它围绕「数据建模」长出了一整套生态:

  • pydantic-settings:专门给「配置类」用的。从环境变量、.env 文件、命令行参数加载配置,自动校验。FastAPI 应用通常用它管 DATABASE_URLSECRET_KEY 这些。安装:pip install pydantic-settings
  • mypy + 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 识别

输出:

Bad(b=2)

坑二:可变默认值必须用 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=Falsecompare=False 控制 repr 和比较行为
  • frozen=True 让 dataclass 不可变——副产品是自动生成 __hash__,能放进 set、能当 dict key,做「值类型」必备
  • __post_init__ 是构造钩子——派生计算、校验都靠它(但校验只是权宜之计)
  • Python 3.10+ 加了 kw_only=Trueslots=True——前者把字段改成强制关键字传参,对继承场景特别友好;后者用 __slots__ 替代 __dict__,省内存、提性能
  • NamedTuple 是「带名字的元组」、TypedDict 是「带类型的字典」——dataclass 之外的两个轻量级选项,各有各的甜蜜点
  • dataclass 不做运行时校验——这是它最大的局限。外部脏数据进来直接照单全收
  • Pydantic 补上了这块——BaseModel 一继承,类型校验、约束校验、智能转换、JSON 互转全到位
  • Pydantic 用 Field(...) 表达约束——gtgeltlemin_lengthmax_lengthpattern 这些参数把字段约束写得清清楚楚
  • EmailStrHttpUrl 这些语义类型让常见校验场景一行搞定
  • 「dataclass 处理内、Pydantic 处理外」——这是各位以后做架构选型最重要的一句话
  • 小实战展示了真实模式——边界用 OrderRequest 校验,内部用 Order 表达业务对象,两者各自发光

这一篇内容比较密。各位不用一次都消化掉——dataclass 那部分可以现在就用起来,Pydantic 那部分等真的碰到「外部数据进来要校验」的场景,再回头翻一下就够。

下一篇咱们聊 Python 里另一个又常见又容易写错的主题——上下文管理器(with 语句)。从 with open(...) as f 这个最常见的姿势出发,把 __enter__ / __exit__ 协议、contextlib、异步上下文管理器一路捋清楚。咱们下篇见。