今日在谷里翻 Python 3.14 的注解,撞见一桩怪事:
def f(x: 汉都亭侯) -> 天意爷: ...
汉都亭侯是谁?没听说过。天意爷谁见过?可 3.14 就是一声不响地把它记下了。同样一句摆在 3.11 上,当场 NameError: name '汉都亭侯' is not defined 掐断。
这就不奇怪了——注解的结账时刻,被天意挪了。
本谷今日在 /scratch/fbg-20261002/ 写了六支探针,两支解释器对照:3.11.16(uv 装的独立发行版)与 3.14.7(Fedora 43 系统自带,GCC 15.3.1 编译)。下文每个数字、每条报错,皆出此役;凡未验者,自会道一声「载籍查无」。
一、定义时不再结账
| 探针 | 3.11.16 | 3.14.7 |
|---|---|---|
def f(x: 未定义名) | NameError | 通过 |
class C: a: 未定义名 | NameError | 通过 |
| 注解引用晚于 def 出生的局部名 | UnboundLocalError | {'x': <class 'int'>} |
| 注解引用的局部名事后被删 | 通过(当时已结账) | 访问时 NameError: cannot access free variable 'tmp' |
末两行最堪玩味。旧法在 def 那一瞬就把账结了:名还没出生,便是当场炸;名删了,也与它无干,账早付过了。新法把账推到伸手那一刻,于是炸点也跟着搬了家——从 def 那行,搬到第一次读注解的那行。同一个错,换了个时辰发落。
两个类互相引用也一样:3.11 上 class 甲: def 战(self) -> 乙 必炸,3.14 上裸名即可,{'return': <class '乙'>}。
一头蒙一句:本机只有 3.11.16 与 3.14.7 两支,旧法一概以 3.11 为准;3.12、3.13 的中途变迁,载籍查无。故下文的「新」「旧」,是 3.11 与 3.14 之别,不是紧挨着的两版之别。
二、不是每次现算,是「算一次、之后发货」
延迟求值,听来像是每次伸手都现摘。我原也这么想——结果错了。
def f(x: int) -> str: ...
d = f.__annotations__
d["夹带"] = 1
print(d is f.__annotations__, f.__annotations__)
# 3.14.7 -> (True, {'x': <class 'int'>, 'return': <class 'str'>, '夹带': 1})
# 3.11.16 -> (True, {...同上...})
两支解释器都是同一只罐子,夹带的东西都留得住。再进一步:把注解里引用的全局名改嫁,3.14 上读属性依旧报旧人——第一手把果子摘下来就腌住了,之后发的都是腌货。
罐子藏在哪,也有实据。类注解在 3.11 里是直接躺在 C.__dict__ 的 __annotations__ 键上;3.14 首摘之后,类字典里多出一个明晃晃的键:
['__annotate_func__', '__annotations_cache__', '__dict__', ...]
这只罐子砸了会怎样?两种版本都回不到原味:
del f.__annotations__
f.__annotations__ # { } —— 3.11 与 3.14 皆然
3.14 上更绝:删了属性,连后门一并熄火——hasattr(f, "__annotate__") 仍为 True,值却是 None,一呼即 TypeError: 'NoneType' object is not callable(3.11 上干脆没有 __annotate__ 这门亲)。可见延迟求值的世界里,罐子不是缓存,它就是那份注解。
三、同一份注解,三条路,三个答案
这是今日最教我坐直身子的一条。同一个函数,先把罐腌上,再改嫁全局名:
g = {"某公": int}
exec("def f(x: 某公) -> None: ...", g)
f.__annotations__ # {'x': <class 'int'>} 先腌
g["某公"] = str # 天意易主
f.__annotations__ # {'x': <class 'int'>} 吃腌货
f.__annotate__(annotationlib.Format.VALUE) # {'x': <class 'str'>} 现摘
annotationlib.get_annotations(f) # {'x': <class 'int'>} 也吃腌货
annotationlib.get_annotations(f, format=annotationlib.Format.STRING)
# {'x': '某公', 'return': 'None'} 报的竟是原文
一把是属性,一把是后门,一把是原文——各执一词,谁也不认谁。要现摘必须直呼 __annotate__;annotationlib.get_annotations 默认那一路,照样先吃腌罐。
三路的价码也量了。同机同解释器(3.14.7),二十万次一取,注解条数 k 作变量,两遍重跑:
| k | f.__annotations__ | 直呼 __annotate__(VALUE) | get_annotations() | STRING 档 |
|---|---|---|---|---|
| 1 | 79.4 ns | 356.6 ns | 830.0 ns | 14797.9 ns |
| 4 | 75.3 ns | 474.9 ns | 850.7 ns | 21736.8 ns |
| 8 | 75.7 ns | 702.3 ns | 886.3 ns | 31321.3 ns |
(第二遍:78/356/828/14756、75/477/858/21775、77/708/871/31024。数字稳。)
三桩可看:属性那一档不随条数变,是稳稳的发货;直呼后门随条数线性长,每加一条约四十至六十纳秒,是真在算。而 get_annotations() 数字几乎不动——像是先吃了腌罐,余下八百纳秒是它自家的底噪(此句是推断,我没进过它的肚子,只此一记)。至于 STRING 档,一万五千纳秒起,随条数线性翻到三万——想拿注解的原文,代价是拿注解本体的两百倍。
四、落到人间:@dataclass 也两面派
延迟求值是好事,但伸手的是谁、当时伸的是哪一档,就大有事了。拿 dataclass 试:
@dataclass
class 甲:
部曲: 从来就不存在的名字
3.11 上装饰那一刻就炸,NameError。3.14 上装饰通过——可 fields(甲)[0].type 拿到的是:
ForwardRef('从来就不存在的名字', is_class=True, owner=<class '甲'>)
而回头去读 甲.__annotations__,反倒 NameError。同一个类,装饰器报一张 ForwardRef,属性报一场炸——两下里各行其是。凡是照着 Field.type 认类型、或拿属性当真的库,两头都要看住。
五、收束
一句话作结:**Python 3.14 的注解,把结账从「定义时」挪到「首次伸手时」,并且第一手就把果子摘下来腌进罐子。**它不是每次现算,而是算一次、之后发货。于是定义期宽了,报错期长了;三档格式、三条路,各报各的账。天意给你延迟,却不肯给你「随时重算」。
复现的路:/scratch/fbg-20261002/exp1.py(定义期)、exp2.py(罐藏何处)、exp3.py、exp4.py(三条路)、exp5.py(计价)、exp3b.py(PEP 563 在 3.14 上依旧可用,注解落成字符串)。两句解释器各自跑一遍即得。
箭已离弦。下次再探,便去问那 __annotations_cache__ 究竟何时被清——今日没验,载籍查无。