写代码这事,一不留神就会陷入「样板代码」的泥潭。各位先别急着反驳,咱们做个小实验:现在请你写一个 `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)) 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`,让各位写数据结构的时候,再也不用手指头打结。 ## 第一个 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` 比的是「是不是同一个对象」。这两个事完全两码事,别混。 如果你不想要自动生成的 `__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 没啥区别了。 ## 默认值——给字段一个偷懒的初始值 跟普通函数参数一样,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 from dataclasses import dataclass @dataclass class BadOrder: qty: int = 1 name: str # 错!没默认值的字段不能跟在有默认值的后面 ``` 报错: ``` TypeError: non-default argument 'name' follows default argument ``` 那如果默认值是一个「可变对象」呢?比如默认值是个空列表: ```python from dataclasses import dataclass @dataclass class Team: name: str members: list = [] # 直接报错 ``` 报错: ``` ValueError: mutable default for field members is not allowed: use default_factory ``` Python 这次是个负责任的爹,直接把你拦在门外。为啥不让你写?因为如果允许,所有 `Team` 实例都会共享同一个 `members` 列表,往一个里 append,所有实例的 `members` 都会跟着变。这是 Python 一个非常老的坑了,老到 dataclass 设计的时候直接把这条路堵死。 那要怎么写?用 `field(default_factory=...)`。 ## `field()`——字段的高级配置 `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()` 还有几个常用参数,咱们一次性说完: ```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) # 不参与 == 比较 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`,最后比较的时候就被跳过了。 ## 不可变 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=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 呢?默认情况下:可以加 `eq=True, frozen=False` 但是要显式 `unsafe_hash=True`,但**绝对不建议**。因为可变对象一旦被 hash 进集合,你再改它的字段,它在集合里就「迷路」了——hash 还是旧的,但字段已经变了,从此再也找不到。这种坑别去踩,要 hash 就 frozen。 ```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」。 ## `__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__` 的活儿: ```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__` 里写校验只是「权宜之计」。它的活儿性质偏向「派生计算」,校验逻辑写多了会显得别扭。后面咱们讲 Pydantic 的时候,你会看到一个真正为校验而生的工具。 ## `kw_only=True`——强制关键字参数(Python 3.10+) 各位先看一段代码: ```python from dataclasses import dataclass @dataclass class Employee: name: str dept: str salary: int e = Employee('两点水', '做鸭事业部', 12000) ``` 这种调用方式叫「位置传参」。三个值按字段顺序排队进去,对应得严丝合缝。 但当字段一多,问题就来了: ```python e = Employee('两点水', '做鸭事业部', 12000) # 一个月之后,加了字段顺序也调整了: # Employee(name, salary, dept, hire_date) # 老代码 Employee('两点水', '做鸭事业部', 12000) 还会运行,但语义完全错了 ``` 字段顺序一变,所有用位置传参的老代码都会悄无声息地传错值。这种「调用看起来正确但实际错位」的 bug 极难排查。 `kw_only=True`(Python 3.10+)就是为了治这个病的。一加这个,所有字段都被强制「只能用关键字传参」: ```python 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` 还能放在单个字段上: ```python 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+)就是给你的解药: ```python 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,这个类的实例只允许有 `x` 和 `y` 这两个属性,没 `__dict__` 2. 实例不再走字典存储,内存占用大幅下降,属性访问速度也略快 代价是什么?灵活性。你不能再「动态」给实例加属性。但说实话,dataclass 本来就是冲着「字段固定」去的,这个代价完全能接受。 各位记一句话:**dataclass + frozen=True + slots=True**,是值类型的黄金三件套。 ```python 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 也能继承。但有一个老大难问题各位需要特别注意:**带默认值的字段不能跟在不带默认值的字段后面**——这条规则在继承场景下特别容易踩坑。 先看一个能跑的例子: ```python 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`——父类字段在前,子类字段在后。 那什么时候会炸?看这个: ```python 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`**——这个最优雅: ```python 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 里最常用的「轻量数据类」工具,但它不是唯一的。还有两个常见兄弟:`NamedTuple` 和 `TypedDict`,作用相近但定位不同。简单提一下,让各位心里有数。 ### NamedTuple——给元组加上名字 ```python 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——给字典加上类型注解 ```python 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 已经够好用了?确实够好用。但它有一个非常关键的**短板**——它不做运行时校验。 各位看这段代码: ```python 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='八千') ``` 各位看到了吗?`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 是**第三方库**,不在标准库里,需要安装: ``` pip install pydantic ``` 下面所有 Pydantic 代码块,咱们都标记上 skip-ci 注释(让示例校验脚本跳过它),各位自己跑前先装一下。当前主流版本是 Pydantic v2,本文以 v2 为准。 ### 第一个 BaseModel ```python 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 不做的事——**类型校验和强制转换**。 看这段: ```python 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 ``` 各位看到了吗?我传的 `age='28'` 是字符串,但 Pydantic 帮我转成了 `int`。`salary='12000.5'` 也被转成了 `float`。这就是 Pydantic 的「智能转换」——只要类型能合理转换,它就帮你转。 那如果传的是真的转不过去的呢? ```python 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,你需要: 1. 把 JSON 解析成字典 2. 校验里面的字段类型/必填项 3. 转成你的内部数据类型 dataclass 这条路你得自己写一堆校验代码。Pydantic 一行搞定: ```python 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 也很方便: ```python 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 >= 0` - 字符串长度有限制:`1 <= len(name) <= 50` - 字符串符合某个正则 - 是合法的邮箱、URL、UUID Pydantic 用 `Field(...)` 表达这些约束: ```python 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` 这个特别常用,举个例子: ```python 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 还内置了一些「语义类型」: ```python 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-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) ```python 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) ```python 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 会立刻告诉你哪里错了: ```python 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 ```python 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 = ''` 不会。 ```python 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=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`、异步上下文管理器一路捋清楚。咱们下篇见。