本节目标:把
list[int]、int | str、Annotated[int, ...]这些写在注解里的表达式当成运行时对象来解剖,说清它们是哪些 C 类型、get_origin/get_args怎么解析,以及为什么isinstance拒绝参数化泛型。
适用版本:Python 3.12+(实测 3.14.6)
8.2 typing 的静态与运行时边界
注解是写给静态检查器看的,但注解里的表达式本身在运行时也是对象。list[int] 能被求值、能放进 __annotations__、能被 get_type_hints 取出来——那它到底是什么?本节把这道「静态语义 ↔ 运行时表示」的边界拆开看。站内 Python 类型系统与 Pydantic V2
讲的是「怎么用」,本节讲的是「它在解释器里长什么样」。
一、list[int] 是 types.GenericAlias,不是类
list[int] 里的 list 是类,但 list[int] 整体不是类,而是 types.GenericAlias 的实例:
import sys, types, typing
print("python", sys.version.split()[0])
cases = {
"list[int]": list[int],
"dict[str,int]": dict[str, int],
"typing.List[int]": typing.List[int],
"Callable[[int],str]": typing.Callable[[int], str],
"Annotated[int,'m']": typing.Annotated[int, "m"],
}
for k, v in cases.items():
t = type(v)
print(f"{k:24} type={t.__module__}.{t.__qualname__}")
输出(本机 3.14.6):
list[int] type=types.GenericAlias
dict[str,int] type=types.GenericAlias
typing.List[int] type=typing._GenericAlias
Callable[[int],str] type=typing._CallableGenericAlias
Annotated[int,'m'] type=typing._AnnotatedAlias
list[int] 走的是内置类型上的 __class_getitem__(list 本身有这个钩子),返回一个轻量的 types.GenericAlias;而 typing.List[int] 走的是 typing 模块里的 _GenericAlias。两条路都能下标,但类型不同。GenericAlias 保留了原始类与参数:
print(list[int].__origin__, list[int].__args__) # <class 'list'> (<class 'int'>,)
print(list[int].__parameters__) # ()
T = typing.TypeVar("T")
print(list[T].__parameters__) # (~T,)
print(list[int]()) # [] —— 可用 __origin__ 实例化
print(types.GenericAlias(list, int)) # list[int]
__parameters__ 是区分「具体别名」与「未绑定泛型」的关键:list[int].__parameters__ 为空,说明它已完全参数化;list[T] 带一个自由类型变量。
二、int | str 在 3.14 与 typing.Union 合并了(版本差异)
这是 3.14 最容易被忽略的一处变化。在 3.10–3.13,int | str 产生的是 types.UnionType 实例,而 typing.Union[int, str] 产生的是 typing 里的另一种对象;3.14 把两者统一成了同一个 C 类型:
import typing, types
print(type(int | str)) # <class 'typing.Union'>
print(types.UnionType is typing.Union) # True
print(type(typing.Union[int, str])) # <class 'typing.Union'>
print(repr(typing.Union[int, str])) # int | str(旧版是 "typing.Union[int, str]")
print(typing.Union[int, str] == (int | str)) # True
输出:
<class 'typing.Union'>
True
<class 'typing.Union'>
int | str
True
Python 3.14 的 What’s New 对此的原话是:“The types.UnionType and typing.Union types are now aliases for each other”,并特别提醒了一个可观测差异——旧式 Union[...] 不再缓存:
print(typing.Union[int, str] is typing.Union[int, str]) # False(3.13 及以前为 True)
print((int | str) is (int | str)) # False
所以比较联合类型必须用 ==,不能用 is。同理 typing.Optional[int] 现在就是 int | None,repr 也是 int | None。这一条对写运行时反射、缓存类型对象的库影响最大——曾经「同参数必返回同一对象」的假设已经失效。
三、get_origin / get_args 的解析口径
不要直接读 .__origin__ / .__args__,它们和 typing.get_origin / get_args 的口径并不一致,Annotated 是最典型的反例:
from typing import get_origin, get_args, Annotated
A = Annotated[int, "meta1", 42]
print("__origin__ =", A.__origin__) # <class 'int'>
print("get_origin(A) =", get_origin(A)) # typing.Annotated
print("__args__ =", A.__args__) # (<class 'int'>,)
print("get_args(A) =", get_args(A)) # (<class 'int'>, 'meta1', 42)
print("A.__metadata__ =", A.__metadata__) # ('meta1', 42)
对照表(本机 3.14.6):
| 表达式 | type | get_origin | get_args |
|---|---|---|---|
list[int] | types.GenericAlias | list | (int,) |
tuple[int, ...] | types.GenericAlias | tuple | (int, Ellipsis) |
Callable[[int], str] | _CallableGenericAlias | collections.abc.Callable | (int, str) |
int | str | typing.Union | typing.Union | (int, str) |
Annotated[int, "m"] | _AnnotatedAlias | typing.Annotated | (int, "m") |
list(未参数化) | type | None | () |
读法:get_origin 负责「这是什么形状」,get_args 负责「里面的参数是什么」。要写能同时吃下内置泛型、typing 泛型、联合类型、Annotated 的通用代码,就必须走这两个函数,而不是碰私有属性。
四、PEP 695 的 type 别名:又一种运行时对象
3.12 引入的 type X = ... 语法,在运行时产生的是 typing.TypeAliasType——一个具名的类型对象,和匿名别名 list[int] 不是一回事:
from typing import get_origin, get_args
type IntList = list[int]
type Pair[T] = tuple[T, T]
print(type(IntList)) # <class 'typing.TypeAliasType'>
print(IntList.__value__) # list[int]
print(get_origin(IntList)) # None —— get_origin 不解包 TypeAliasType
print(Pair[int], type(Pair[int])) # Pair[int] <class 'types.GenericAlias'>
print(get_args(Pair[int])) # (<class 'int'>,)
print(IntList.__name__) # IntList
输出:
<class 'typing.TypeAliasType'>
list[int]
None
Pair[int] <class 'types.GenericAlias'>
(<class 'int'>,)
IntList
两个要点:get_origin 不会穿过 TypeAliasType,要拿到底层类型必须读 .__value__;而泛型别名 Pair[T] 一旦下标,得到的又回到普通的 types.GenericAlias。所以「一个名字代表一个类型」这件事,在运行时至少有三种载体:GenericAlias(匿名参数化)、TypeAliasType(具名别名)、以及普通的类。
五、为什么 isinstance 用不了参数化泛型
isinstance 的第二个参数必须是类或由类组成的元组。参数化泛型是 GenericAlias 实例,不是类,于是被直接拒绝——而且两条路的报错文案还不一样:
def probe(v, t):
try: return isinstance(v, t)
except Exception as e: return f"{type(e).__name__}: {e}"
print(probe([], list[int])) # TypeError: isinstance() argument 2 cannot be a parameterized generic
print(probe([], typing.List[int])) # TypeError: Subscripted generics cannot be used with class and instance checks
print(probe([], list)) # True
print(probe(1, int | str)) # True —— 联合类型是特例,支持 isinstance
print(probe(1.5, int | str)) # False
注意 int | str 是唯一支持 isinstance/issubclass 的「复合类型」:它本质上是一个类(typing.Union),__instancecheck__ 会逐个试成员。这也解释了为什么 Union 会被并入类体系,而 list[int] 不会。
要在运行时检查「是不是 list」,只能退到 get_origin:
from typing import get_origin
ann = list[int]
print(get_origin(ann) is list) # True
print(isinstance([], get_origin(ann))) # True
但这样只校验了外层容器,元素类型 int 完全没有被检查——这正是运行时校验库存在的根本原因。
六、Annotated:把元数据挂在类型上
Annotated[T, ...] 的第一个参数是真实类型,其余是任意元数据。它是 FastAPI、Pydantic 等框架做「声明式约束」的载体。注意 get_type_hints 默认会丢掉元数据:
from typing import Annotated, get_type_hints
class Model:
items: list[int]
name: Annotated[str, "用户名"]
def method(self, x: dict[str, list[int]]) -> "Model | None": ...
print(get_type_hints(Model)) # {'items': list[int], 'name': <class 'str'>}
print(get_type_hints(Model, include_extras=True))# {'items': list[int], 'name': Annotated[str, '用户名']}
print(get_type_hints(Model.method)) # {'x': dict[str, list[int]], 'return': Model | None}
输出:
{'items': list[int], 'name': <class 'str'>}
{'items': list[int], 'name': typing.Annotated[str, '用户名']}
{'x': dict[str, list[int]], 'return': __main__.Model | None}
两个要点:想读元数据必须显式传 include_extras=True;get_type_hints 还会解析字符串注解("Model | None" 被解析成真实对象),它内部用的正是下一节要讲的惰性注解机制。
七、实测:运行时校验到底多贵
既然 isinstance 管不了元素类型,运行时校验只能自己遍历或交给库。用 20 个元素的 list[int] 实测三种做法(本机 3.14.6,各 20 万次):
import timeit
from pydantic import TypeAdapter
ta = TypeAdapter(list[int])
data = list(range(20))
def manual(v):
if not isinstance(v, list): raise TypeError
return v
def manual_full(v):
if not isinstance(v, list) or not all(isinstance(x, int) for x in v): raise TypeError
return v
def ns(fn, n=200_000):
return timeit.timeit(lambda: fn(data), number=n) / n * 1e9
print(f"手写 isinstance(整体) : {ns(manual):7.1f} ns")
print(f"手写 isinstance(逐元素): {ns(manual_full):7.1f} ns")
print(f"pydantic TypeAdapter : {ns(ta.validate_python):7.1f} ns")
输出(本机 3.14.6,单次测量,计时数值存在波动):
手写 isinstance(整体) : 56.6 ns
手写 isinstance(逐元素): 652.6 ns
pydantic TypeAdapter : 455.9 ns
结果有点反直觉:逐元素手写校验比 Pydantic 还慢(653 ns 对 456 ns),因为 Pydantic V2 的核心校验器是编译后的 Rust 代码,而 Python 层的 for + isinstance 每次都要走一次解释器循环。这条数据也说明:运行时校验的成本主要来自「逐元素」,与「用不用库」关系没那么大。要不要付出这个成本,取决于数据是否来自不可信边界——这与 Python 编程入门 · 运行时校验与 Pydantic
的结论一致。
小结
list[int]是types.GenericAlias的实例,不是类;typing.List[int]是另一套_GenericAlias;两者靠__origin__/__args__承载参数。- 3.14 把
types.UnionType与typing.Union合并成同一个 C 类型,repr变为int | str,且旧式Union[...]不再缓存——比较联合类型要用==而不是is。 - 解析类型结构一律用
get_origin/get_args,不要读__origin__/__args__;Annotated的两套口径差异最明显。 isinstance拒绝参数化泛型(两条报错文案不同),但接受int | str;要在运行时检查容器只能退到get_origin,且丢掉了元素类型。Annotated的元数据需要get_type_hints(..., include_extras=True)才读得到。- 实测:20 元素的
list[int],逐元素手写校验约 653 ns,比 Pydantic 的 456 ns 还慢——校验成本主要来自逐元素遍历。
下一节 注解的求值时机(PEP 649/749) 会解释:既然注解是运行时对象,那它到底是什么时候被求值的?3.14 给出的答案是「延迟到真正需要时」。
阅读导航:上一节:描述符协议与属性查找 · 下一节:注解的求值时机(PEP 649/749) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。