技术 Python

玩转 Python 运行时类型:实战指南(Pydantic v2 & 泛型全解)

一篇带你边走边学的实用手记:教你如何在运行时恢复类型——从基础的 typing.get_args/get_origin,到 Pydantic v2 泛型、前向引用,再到如何优雅避开 rebuild 地狱。

曾几何时,我一直以为 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] 真的是一个子类,不是类型别名。

    • 推荐这样做:

      1. 先看 self.__class__.__pydantic_generic_metadata__["args"](如果有),或者
      2. 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from typing import get_origin, get_args, Optional

tp = list[int]
print(get_origin(tp))      # <class 'list'>
print(get_args(tp))        # (int,)

tp = dict[str, float]
print(get_origin(tp))      # <class 'dict'>
print(get_args(tp))        # (str, float)

tp = Optional[int]         # Union[int, NoneType]
print(get_origin(tp))      # types.UnionType 或 typing.Union
print(get_args(tp))        # (int, NoneType)

口诀: 这些方法就是为类型表达式准备的。如果你拿的是个“类”,它们就不一定有用了。


Step 2 — Pydantic v2 泛型:为什么 get_args(self.__class__) 经常扑空

在 Pydantic v2 里,像 Message[int] 这样,其实会生成一个新的真子类,不是“类型别名”那么简单。所以:

1
args = get_args(self.__class__)  # 多半返回 ()

类型参数变成了 Pydantic 的元数据,或者已经写进了字段注解

实战套路(直接复制粘贴用)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from typing import Generic, TypeVar, get_args, get_origin
from pydantic import BaseModel, computed_field

T = TypeVar("T")

def _pretty(tp) -> str:
    # 普通类/内置类型
    if hasattr(tp, "__name__"):
        return tp.__name__
    # typing 类型表达式(如 list[int]、dict[str, int] 等)
    origin = get_origin(tp)
    if origin is None:
        return str(tp)
    inner = ", ".join(_pretty(a) for a in get_args(tp))
    base = getattr(origin, "__name__", str(origin))
    return f"{base}[{inner}]"

class Message(BaseModel, Generic[T]):
    content: T

    @computed_field
    @property
    def content_class_name(self) -> str:
        # 1) 读 Pydantic 的泛型元数据(最直接)
        meta = getattr(self.__class__, "__pydantic_generic_metadata__", None)
        if meta and meta.get("args"):
            return _pretty(meta["args"][0])

        # 2) 字段注解(已经按泛型参数替换好了)
        ann = self.__class__.model_fields["content"].annotation
        if ann is not None:
            return _pretty(ann)

        # 3) 兜底:直接看实际值的类型
        return _pretty(type(self.content))

举几个栗子:

1
2
3
4
5
6
7
print(Message[int](content=1).content_class_name)                 # "int"
print(Message[list[int]](content=[1, 2]).content_class_name)      # "list[int]"
print(Message(content="hi").content_class_name)                   # "str"(兜底)

class User(BaseModel): id: int
print(Message[User](content=User(id=1)).content_class_name)       # "User"
print(Message[dict[str, int]](content={"a": 1}).content_class_name) # "dict[str, int]"

结论: 这个套路对各种 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 时早就人去楼空了。
  • 循环引用要在“解析时”所有名字都在,光“定义时”可不够。

一个小工具,救你一命

现场保存“本地命名空间”,后续解析时带上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import inspect
from typing import get_type_hints

def capture_localns():
    # 在你创建模型/注册表的地方调用
    frame = inspect.currentframe()
    assert frame and frame.f_back
    return frame.f_back.f_locals.copy()

# 用法举例
def make_models():
    class A(BaseModel): b: 'B'
    class B(BaseModel): a: A
    localns = capture_localns()
    # 后面解析时...
    hints = get_type_hints(A, localns=localns)  # 能解析 'B'
    return A, B

有些框架会自动帮你做这步(一次 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
  • 想要好看的类型名?

    • _pretty() 这种方法,涵盖 list[int]、Union、Annotated 等等

常见“灵魂拷问区”

Q: Tlist[...] 跟 Pydantic 模型时,逻辑要分开写吗?
A: 不用!数据来源(元数据、注解、值)不同,但 _pretty() 都能一视同仁搞定。

Q: 为什么 model_fields["content"].annotation 已经是类型替换过的?
A: 因为 Pydantic v2 在生成子类(比如 Message[int])时会专门把注解替换掉,字段的 annotation 通常已经是实际类型了。

Q: 读 __pydantic_generic_metadata__ 算“私有”吗?
A: 半公开吧,但目前它最靠谱。建议用个小工具包一层,将来 Pydantic 改了好调整。


一键复制进项目的小工具

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# runtime_types.py
from typing import Any, get_args, get_origin

def pretty_type_name(tp: Any) -> str:
    if hasattr(tp, "__name__"):
        return tp.__name__
    origin = get_origin(tp)
    if origin is None:
        return str(tp)
    inner = ", ".join(pretty_type_name(a) for a in get_args(tp))
    base = getattr(origin, "__name__", str(origin))
    return f"{base}[{inner}]"

def pydantic_T(cls: type, field: str):
    """返回 Pydantic 泛型字段的 (tp, 来源),能拿就拿。"""
    meta = getattr(cls, "__pydantic_generic_metadata__", None)
    if meta and meta.get("args"):
        return meta["args"][0], "pydantic_meta"
    ann = getattr(cls, "model_fields", {}).get(field, None)
    if ann and getattr(ann, "annotation", None) is not None:
        return ann.annotation, "field_annotation"
    return None, "unknown"

用法示例:

1
2
tp, src = pydantic_T(self.__class__, "content")
name = pretty_type_name(tp) if tp else pretty_type_name(type(self.content))

思维转弯:一切迎刃而解!

我后来不再纠结“Python 为啥不给我类型”,而是直接问自己:

“我到底在检查啥对象?我要在哪个上下文解析?”

  • 类型表达式有结构 → get_origin/get_args
  • 特制子类有自己的元数据和注解
  • 前向引用只要带对 namespace 就没毛病

心里有了这三条“通道”,类型地雷阵也就变大路了。


彩蛋:我还会犯的错(你别跟着踩)

  • 对着 class 调 get_args(),一脸懵逼收获 ()
  • 以为 mypy 过了运行时就一定没事(其实俩世界)
  • 忘记函数作用域的名字 introspect 时早没影了
  • 到处 .model_rebuild(),其实保存好 namespace 就完事

如果这篇帮你少熬了一个通宵,那我码字就值了!😃


(人类作者原创,部分内容借助 AI 优化表达。)