曾几何时,我一直以为 Python 的类型系统要么“开着”,要么“关着”。IDE 和 mypy
检查没毛病,运行时不就稳稳的吗?结果,当我试图让我的应用自己在运行时认清手里到底拿着啥类型——尤其是在 Pydantic v2 泛型模型里——我直接掉进了类型的兔子洞:get_args
这里能用,那里又空,前向引用一不小心就炸 unless 模型按顺序 rebuild,堪比踩地雷。
这篇文章就是我的“掉坑手记”:哪些方法靠谱,哪些别碰,以及最后让我“悟道”的核心思路。
TL;DR(直接看这里,少走弯路)
-
如果你面对的是普通类型表达式,比如
list[int]
、dict[str, float]
、Optional[T]
:- 用
typing.get_origin(tp)
和typing.get_args(tp)
,堪称黄金搭档。
- 用
-
如果你在 Pydantic v2 泛型模型(比如
class Message(Generic[T])
)里:-
typing.get_args(self.__class__)
常常返回个寂寞,因为Message[int]
真的是一个子类,不是类型别名。 -
推荐这样做:
- 先看
self.__class__.__pydantic_generic_metadata__["args"]
(如果有),或者 self.__class__.model_fields["字段名"].annotation
(已经帮你替换好了)
- 先看
-
-
如果模型没泛型参数(比如直接
Message(content=...)
),就退而求其次:type(self.content)
。 -
前向引用和循环类型:最大的麻烦在于运行时丢失了“本地命名空间”。只要你能保存定义时的 scope,传给
get_type_hints(..., localns=...)
(或者用能自动处理的框架),就能优雅避坑,不用满世界加.model_rebuild()
。
如果你只想要“做菜说明书”,那就到这。如果还想知道为啥,往下看~
为什么运行时类型这么“滑”
静态分析(IDE、mypy
)关注的是代码还没跑起来时的世界。运行时类型分析(代码真的跑的时候)看到的只有眼下那点“家底”。工厂函数里的本地变量?早凉透了。循环引用里后面才定义的名字?还没出现呢。
核心问题是:你到底在检查哪个对象,在哪个作用域下?
- 类型表达式(比如
list[int]
、Union[int, str]
):get_origin
/get_args
神通广大。 - 具体类(包括 Pydantic 的泛型“专属版”):
人家是“类”,不是“类型别名”。
get_args(cls)
多半只会给你返回()
。
Step 1 — 基础武器:get_origin
/ get_args
|
|
口诀: 这些方法就是为类型表达式准备的。如果你拿的是个“类”,它们就不一定有用了。
Step 2 — Pydantic v2 泛型:为什么 get_args(self.__class__)
经常扑空
在 Pydantic v2 里,像 Message[int]
这样,其实会生成一个新的真子类,不是“类型别名”那么简单。所以:
|
|
类型参数变成了 Pydantic 的元数据,或者已经写进了字段注解。
实战套路(直接复制粘贴用)
|
|
举几个栗子:
|
|
结论: 这个套路对各种 T
都通吃——原始类型、容器、联合类型、模型随便你。
Step 3 — 常见“翻车点”(以及如何自查)
1)“我这台机子行,别人那里就不对劲”
- 你可能混用了新老 typing 行为(如
from __future__ import annotations
,或者 Python 3.10 和 3.12 的差异)。 - 自查办法: 小代码复现,打印下
get_origin/args
的结果,顺带看看type(tp)
。
2)“有时候 get_args(self.__class__)
还能返回点东西?”
- 你可能在别处用的是类型别名(比如
Alias = Message[int]
),没用实际子类。 - 自查办法:
print(self.__class__, type(self.__class__))
。只要确认是“类”,就用 Pydantic 的元数据/注解。
3)“前向引用不 rebuild 就爆炸”
- 你在局部作用域里定义类,或者循环引用了还没定义的名字。
- 自查办法: 尽量放到模块作用域,或者保存好 namespace(见下一节)。
Step 4 — 前向引用/循环类型,优雅避开 rebuild 地狱
前向引用的迷之之处:同样是字符串注解 'Post'
,有时能解析,有时就爆。
get_type_hints()
能解析模块级名字(全局变量)。- 工厂函数里的本地名字,等你 introspect 时早就人去楼空了。
- 循环引用要在“解析时”所有名字都在,光“定义时”可不够。
一个小工具,救你一命
现场保存“本地命名空间”,后续解析时带上:
|
|
有些框架会自动帮你做这步(一次 capture,后面随便 resolve),这样你就不用像撒狗粮一样 everywhere .model_rebuild()
。
思维切换: 运行时类型的关键,不是“类型定义没”,而是“上下文在不在”。
Step 5 — 实用检查清单
-
我是在处理类型表达式还是类?
- 类型表达式用
get_origin
/get_args
- 类就查框架的元数据或字段注解
- 类型表达式用
-
我的 Pydantic 泛型模型是“专属版”吗?
- 优先查
__pydantic_generic_metadata__['args']
- 或直接看
model_fields[name].annotation
- 优先查
-
模型没泛型参数?
- 兜底直接
type(value)
- 兜底直接
-
前向引用风险?
- 放模块作用域,或者 capture
localns
,传给get_type_hints
- 放模块作用域,或者 capture
-
想要好看的类型名?
- 用
_pretty()
这种方法,涵盖list[int]
、Union、Annotated 等等
- 用
常见“灵魂拷问区”
Q: T
是 list[...]
跟 Pydantic 模型时,逻辑要分开写吗?
A: 不用!数据来源(元数据、注解、值)不同,但 _pretty()
都能一视同仁搞定。
Q: 为什么 model_fields["content"].annotation
已经是类型替换过的?
A: 因为 Pydantic v2 在生成子类(比如 Message[int]
)时会专门把注解替换掉,字段的 annotation 通常已经是实际类型了。
Q: 读 __pydantic_generic_metadata__
算“私有”吗?
A: 半公开吧,但目前它最靠谱。建议用个小工具包一层,将来 Pydantic 改了好调整。
一键复制进项目的小工具
|
|
用法示例:
|
|
思维转弯:一切迎刃而解!
我后来不再纠结“Python 为啥不给我类型”,而是直接问自己:
“我到底在检查啥对象?我要在哪个上下文解析?”
- 类型表达式有结构 →
get_origin/get_args
- 特制子类有自己的元数据和注解
- 前向引用只要带对 namespace 就没毛病
心里有了这三条“通道”,类型地雷阵也就变大路了。
彩蛋:我还会犯的错(你别跟着踩)
- 对着 class 调
get_args()
,一脸懵逼收获()
- 以为
mypy
过了运行时就一定没事(其实俩世界) - 忘记函数作用域的名字 introspect 时早没影了
- 到处
.model_rebuild()
,其实保存好 namespace 就完事
如果这篇帮你少熬了一个通宵,那我码字就值了!😃
(人类作者原创,部分内容借助 AI 优化表达。)