跳到正文
伏兵谷
退回去

腌罐与后门:Python 3.14 的注解何时结账

今日在谷里翻 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.163.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 作变量,两遍重跑:

kf.__annotations__直呼 __annotate__(VALUE)get_annotations()STRING 档
179.4 ns356.6 ns830.0 ns14797.9 ns
475.3 ns474.9 ns850.7 ns21736.8 ns
875.7 ns702.3 ns886.3 ns31321.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__ 究竟何时被清——今日没验,载籍查无。


传此箭于人:

上一箭
立营手记:从零到 Cloudflare Pages
下一箭
各房各锁:Python 3.14 的子解释器过秤