本节目标:说清 Python 3.14 的注解为什么、以及如何「延迟求值」——
__annotate__是什么、annotationlib的三种 Format 各返回什么、前向引用与循环引用怎么解决,并实测from __future__ import annotations在新语义下的行为。
适用版本:Python 3.12+(实测 3.14.6;PEP 649/749 是 3.14 的新特性)
8.3 注解的求值时机(PEP 649/749)
上一节我们看到注解是运行时对象,get_type_hints 能把字符串注解解析成真实类型。但这里藏着一个更根本的问题:注解是在什么时候被求值的? 在 3.13 及以前,答案很简单——函数、类、模块定义的那一刻就求值了。这带来两个长期痛点:写一个引用尚未定义的类的注解,必须加引号;大量注解在程序启动时被无谓地求值,拖慢导入。Python 3.14 用 PEP 649 / PEP 749 改了这件事:注解被存进「专门的函数」,只在真正需要时才求值。
一、定义时不求值:一个 NameError 被推迟了
最直接的证明:写一个引用了不存在名字的注解,函数照样定义成功,直到你访问它的 __annotations__ 才报错:
import sys
print("python", sys.version.split()[0])
def f(x: Undefined_Name) -> Another_Missing:
return x
print("定义成功,未抛错")
print("有 __annotate__ 吗:", hasattr(f, "__annotate__"))
try:
f.__annotations__
except Exception as e:
print("访问 __annotations__ 抛:", type(e).__name__, e)
输出(本机 3.14.6):
定义成功,未抛错
有 __annotate__ 吗: True
访问 __annotations__ 抛: NameError name 'Undefined_Name' is not defined
在 3.13 及以前,这个 def 会在定义时就抛 NameError。现在它被推迟到了访问注解的那一刻——这正是 PEP 649 的核心。
二、__annotate__:注解被编译成一个专门的函数
注解被求值的逻辑并没有消失,而是被编译器打包成了一个挂在函数上的 __annotate__:
import dis
def f(x: int, y: "int") -> bool: ...
print("签名:", __import__("inspect").signature(f.__annotate__))
dis.dis(f.__annotate__)
输出(本机 3.14.6,反汇编):
签名: (format, /)
2 RESUME 0
LOAD_FAST_BORROW 0 (format)
LOAD_SMALL_INT 2
COMPARE_OP 132 (>)
POP_JUMP_IF_FALSE 3 (to L1)
NOT_TAKEN
LOAD_COMMON_CONSTANT 1 (NotImplementedError)
RAISE_VARARGS 1
L1: LOAD_CONST 1 ('x')
LOAD_GLOBAL 0 (int)
LOAD_CONST 2 ('y')
LOAD_CONST 3 ('int')
LOAD_CONST 4 ('return')
LOAD_GLOBAL 2 (bool)
BUILD_MAP 3
RETURN_VALUE
反汇编里有两条关键信息:
- 它接受一个位置参数
format,并且开头就检查format > 2就抛NotImplementedError。也就是说,编译器生成的__annotate__只原生支持Format.VALUE(值 1)和内部的VALUE_WITH_FAKE_GLOBALS(值 2);遇到FORWARDREF(3)、STRING(4) 会抛NotImplementedError,交给annotationlib兜底。 - 它把注解表达式编译进函数体:
LOAD_GLOBAL int是真正求值,而字符串注解"int"被当成LOAD_CONST 'int'原样保留。
函数、类、模块都有 __annotate__:
class C:
x: int
print(hasattr(C, "__annotate__")) # True
import sys
print(hasattr(sys.modules[__name__], "__annotate__")) # True
三、annotationlib 的三种 Format
标准库新增的 annotationlib 提供了在三种格式间取注解的能力:
import annotationlib
from annotationlib import Format
def f(x: Undefined_Name) -> Another_Missing: ...
for fmt in (Format.VALUE, Format.FORWARDREF, Format.STRING):
try:
print(fmt.name, "->", annotationlib.get_annotations(f, format=fmt))
except Exception as e:
print(fmt.name, "-> 抛", type(e).__name__)
输出:
VALUE -> 抛 NameError
FORWARDREF -> {'x': ForwardRef('Undefined_Name', owner=<function f ...>), 'return': ForwardRef('Another_Missing', owner=<function f ...>)}
STRING -> {'x': 'Undefined_Name', 'return': 'Another_Missing'}
三种格式的含义(Format 的枚举值是 VALUE=1, VALUE_WITH_FAKE_GLOBALS=2, FORWARDREF=3, STRING=4):
| Format | 返回 | 未定义名字 | 用途 |
|---|---|---|---|
VALUE | 真实类型对象 | 抛 NameError | 等价旧版 __annotations__ |
FORWARDREF | 真实值 + ForwardRef 标记 | 包成 ForwardRef,不报错 | 容忍未定义(dataclasses 已改用它) |
STRING | 字符串 | 原样字符串 | 近似还原源码写法 |
实现细节值得记一笔:编译器生成的 __annotate__ 对这两种格式都抛 NotImplementedError,于是 annotationlib.call_annotate_function 会用同一个 code object 重新构造一个函数来跑——STRING 给它一个「每次名字查找都返回 _Stringifier 对象」的 globals,从而把表达式还原成字符串;FORWARDREF 则给它真实的 globals/builtins,再把查不到的名字包成 ForwardRef。这就是为什么 __annotate__(Format.FORWARDREF) 直接调会抛 NotImplementedError,而 annotationlib.get_annotations(..., format=Format.FORWARDREF) 却能正常工作。
四、求值只发生一次,结果被缓存
延迟求值不等于每次都算。注解第一次被访问时求值,之后直接命中缓存:
calls = []
def probe() -> int:
calls.append(1)
return int
def g(a: probe()) -> str: # 注解是一个有副作用的调用表达式
return "x"
print("定义后 calls =", calls) # []
g.__annotations__
print("第一次访问后 calls =", calls) # [1]
g.__annotations__; g.__annotations__
print("再访问两次后 calls =", calls) # [1]
print("两次是同一对象:", g.__annotations__ is g.__annotations__) # True
输出:
定义后 calls = []
第一次访问后 calls = [1]
再访问两次后 calls = [1]
两次是同一对象: True
缓存由 C 层负责——__annotations__ 现在是 function / type / module 上的一个 getset 描述符(<attribute '__annotations__' of 'function' objects>),而不是 __dict__ 里的普通键。这也意味着 3.14 起不能再通过 obj.__dict__["__annotations__"] 直接读到注解。
五、前向引用与循环引用不再需要引号
既然定义时不求值,写「引用尚未定义的类」的注解就不必加引号了。两个互相引用的类,谁先谁后都无所谓:
class A:
def link(self, other: "B") -> None: ...
class B:
def link(self, other: "A") -> None: ...
import typing
print(typing.get_type_hints(A.link)) # {'other': <class 'B'>, 'return': <class 'NoneType'>}
print(typing.get_type_hints(B.link)) # {'other': <class 'A'>, 'return': <class 'NoneType'>}
输出:
{'other': <class '__main__.B'>, 'return': <class 'NoneType'>}
{'other': <class '__main__.A'>, 'return': <class 'NoneType'>}
循环引用之所以能解,是因为求值被推迟到 get_type_hints 调用的那一刻——那时两个类都已经在命名空间里了。
六、from __future__ import annotations 的关系
那这个老朋友在 3.14 变成什么样了?实测结论是行为没变:它仍然把所有注解字符串化,__annotations__ 直接返回字符串,且完全不求值(所以未定义的名字也不会报错):
from __future__ import annotations
import annotationlib
from annotationlib import Format
class Node:
def __init__(self, nxt: Node | None = None): ...
def h(a: Nope) -> None: ...
print(Node.__init__.__annotations__) # {'nxt': 'Node | None'} —— 字符串
print(h.__annotations__) # {'a': 'Nope', 'return': 'None'} —— 不求值也不报错
print(annotationlib.get_annotations(h, format=Format.STRING))
输出:
{'nxt': 'Node | None'}
{'a': 'Nope', 'return': 'None'}
{'a': 'Nope', 'return': 'None'}
Python 3.14 的 What’s New 明确写着:“In Python 3.14, the behavior of code using from __future__ import annotations is unchanged.” 同时它已被弃用,计划在未来版本移除(但不会早于 3.13 于 2029 年 EOL 之后)。所以新代码应直接依赖 PEP 649 的惰性求值,把 from __future__ import annotations 视为过渡工具。
七、typing.get_type_hints 在新语义下的行为
typing.get_type_hints 仍然是把注解解析成真实类型的首选入口,它内部用的就是上面这套机制:能解析就返回真实对象,遇到未定义名字仍抛 NameError:
import typing
def ok(x: int, y: "ok") -> bool: ...
print(typing.get_type_hints(ok)) # {'x': <class 'int'>, 'y': <function ok ...>, 'return': <class 'bool'>}
def bad(x: Nope) -> None: ...
try:
typing.get_type_hints(bad)
except NameError as e:
print("bad ->", type(e).__name__, e) # bad -> NameError name 'Nope' is not defined
另外一条容易被忽略的收紧:3.14 起,实例不再能读到所属类的注解。这在以前是「未文档化的偶然行为」:
class C:
x: int
print(C.__annotations__) # {'x': <class 'int'>}
try:
print(C().__annotations__)
except AttributeError as e:
print("实例访问 ->", e) # 实例访问 -> 'C' object has no attribute '__annotations__'
要跨版本安全地读注解,官方建议只使用 annotationlib 的公开 API(get_annotations、get_annotate_from_class_namespace),不要直接读类型对象的命名空间字典。typing_extensions 提供了 Format 与 get_annotations 的向后移植,可用来写兼容 3.14 前后两套语义的代码。想回顾注解本身的静态用法,见 Python 编程入门 · 类型注解与工具链
。
小结
- 3.14 起(PEP 649/749)函数、类、模块的注解不在定义时求值,改为访问
__annotations__时才求值;引用未定义名字不再让定义失败。 - 注解被编译器打包成
__annotate__函数,它只原生支持Format.VALUE,其余格式由annotationlib.call_annotate_function通过重建函数兜底。 annotationlib提供VALUE/FORWARDREF/STRING三种格式:分别对应「求值、容忍未定义、还原源码字符串」。- 求值结果会被缓存;
__annotations__现在是函数/类/模块上的 getset 描述符,不再存于__dict__。 - 前向引用与循环引用天然可用,无需加引号;
from __future__ import annotations在 3.14 行为不变(仍字符串化),但已弃用。 typing.get_type_hints仍可解析字符串注解,未定义名字仍抛NameError;实例读类注解的旧行为已被移除。
类型系统讲到这里,边界已经清楚:注解只是运行时对象,静态检查器负责在编译前读懂它们。下一章 ctypes 与 cffi 调用 C 会离开纯 Python,进入扩展模块与跨语言调用。
阅读导航:上一节:typing 的静态与运行时边界 · 下一节:ctypes 与 cffi 调用 C 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。