CPython 3.14 C API 移除指南:PyDictObject.ma_version_tag 与不可变类型的可变基类
本文基于 CPython 仓库中的弃用说明文档 Doc/deprecations/c-api-pending-removal-in-3.14.rst,系统讲解 Python 3.14 中两项待移除的 C API 变更:PyDictObject 结构体的 ma_version_tag 字段被移除(PEP 699,gh-101193),以及创建带有可变基类的不可变类型(Py_TPFLAGS_IMMUTABLETYPE)被禁止(gh-95388)。读完后,你将了解这两项变更的历史脉络、底层结构布局、替代 API(字典监视器 PyDict_AddWatcher 系列函数)的完整用法,以及扩展模块作者需要的迁移要点。
变更全景:从弃用到移除的两个版本周期
CPython 的 C API 弃用遵循固定的节奏:先在某个版本发出弃用(通常伴随 PyDeprecated 标记或运行时弃用告警),在后续版本正式移除。Doc/deprecations/c-api-pending-removal-in-3.14.rst 原文列出的全部待移除项只有两条:
- 扩展模块中
PyDictObject的ma_version_tag字段(PEP 699;gh-101193); - 创建带有可变基类的
Py_TPFLAGS_IMMUTABLETYPE不可变类型(gh-95388)。
两项都遵循了相同的时间线:Python 3.12 中发出弃用(gh-101193、gh-95388),Python 3.14 中正式移除或收紧为硬错误。Doc/whatsnew/3.14.rst 的 C API 章节对此有对应的移除记录:
* Remove ``PyDictObject.ma_version_tag`` member, which was deprecated
in Python 3.12.
Use the :c:func:`PyDict_AddWatcher` API instead.
(Contributed by Sam Gross in gh-124296.)
以及:
* Creating immutable types with mutable bases was deprecated in
Python 3.12, and now raises a TypeError.
(Contributed by Nikita Sobolev in gh-119775.)
下面结合仓库源码逐项展开。
移除 PyDictObject.ma_version_tag:字典版本号机制的终结
历史脉络
ma_version_tag 字段最早由 PEP 509(3.6 引入的函数内联缓存与字节码特化)加入 PyDictObject,用途是让解释器判断全局变量字典等是否发生过修改,从而决定是否使已特化的字节码失效(例如 LOAD_GLOBAL 的特化需要检测 globals/builtins 字典的版本变化)。到了 PEP 699(函数特化重新设计)阶段,这套"整字典版本号"机制被更细粒度的监视器(watcher)机制取代。仓库的发布记录 Misc/NEWS.d/3.14.0a1.rst 明确记录了这一移除:
:c:type:`PyDictObject` no longer maintains a private version tag field
``ma_version_tag`` per PEP 699. This field was originally added in
Python 3.6 (PEP 509) and deprecated in Python 3.12.
(对应 gh-124296,贡献者 Sam Gross。)
源码证据:3.14 中的 PyDictObject 结构布局
在当前仓库的 Include/cpython/dictobject.h 中,PyDictObject 已不再包含 ma_version_tag,其定义如下:
typedef struct {
PyObject_HEAD
/* Number of items in the dictionary */
Py_ssize_t ma_used;
/* This is a private field for CPython's internal use.
* Bits 0-7 are for dict watchers.
* Bits 8-11 are for the watched mutation counter (used by tier2 optimization)
* Bits 12-31 are currently unused
* Bits 32-63 are a unique id in the free threading build (used for per-thread refcounting)
*/
uint64_t _ma_watcher_tag;
PyDictKeysObject *ma_keys;
/* If ma_values is NULL, the table is "combined": keys and values
are stored in ma_keys.
If ma_values is not NULL, the table is split:
keys are stored in ma_keys and values are stored in ma_values */
PyDictValues *ma_values;
} PyDictObject;
从源码结构看,原来 32 位的 ma_version_tag(uint32_t)的位置由 64 位的私有字段 _ma_watcher_tag 占据,其位段分配为:
- 位 0-7:字典监视器(dict watcher)标记;
- 位 8-11:被监视字典的修改计数器,服务于 tier2 特化优化;
- 位 12-31:当前未使用;
- 位 32-63:在 free threading(无 GIL)构建中存放字典唯一 ID,用于每线程引用计数(见 Include/internal/pycore_dict.h 中的
_PyDict_UniqueId)。
这表明该字段并非被简单删除,而是被重新设计为承载监视器机制的复合位域——扩展模块此前对该字段的直接读写方式已不再适用,也不应再适用。
另外值得注意:面向外部 JIT 的键版本查询接口仍然存在,例如 Include/internal/pycore_dict.h 中的 _PyDict_GetKeysVersionForCurrentState,说明"版本"这一概念在 CPython 内部被保留并私有化了,只是不再以 ma_version_tag 的形式暴露给 C API 消费者。
替代方案:PyDict_AddWatcher 字典监视器 API
Doc/whatsnew/3.14.rst 建议的替代方案是 PyDict_AddWatcher API。该组 API 的完整声明位于 Include/cpython/dictobject.h:
/* Dictionary watchers */
#define PY_FOREACH_DICT_EVENT(V) \
V(ADDED) \
V(MODIFIED) \
V(DELETED) \
V(CLONED) \
V(CLEARED) \
V(DEALLOCATED)
typedef enum {
#define PY_DEF_EVENT(EVENT) PyDict_EVENT_##EVENT,
PY_FOREACH_DICT_EVENT(PY_DEF_EVENT)
#undef PY_DEF_EVENT
} PyDict_WatchEvent;
// Callback to be invoked when a watched dict is cleared, dealloced, or modified.
// In clear/dealloc case, key and new_value will be NULL. Otherwise, new_value will be the
// new value for key, NULL if key is being deleted.
typedef int(*PyDict_WatchCallback)(PyDict_WatchEvent event,
PyObject *dict,
PyObject *key,
PyObject *new_value);
// Register/unregister a dict-watcher callback
PyAPI_FUNC(int) PyDict_AddWatcher(PyDict_WatchCallback callback);
PyAPI_FUNC(int) PyDict_ClearWatcher(int watcher_id);
// Mark given dictionary as "watched" (callback will be called if it is modified)
PyAPI_FUNC(int) PyDict_Watch(int watcher_id, PyObject *dict);
PyAPI_FUNC(int) PyDict_Unwatch(int watcher_id, PyObject *dict);
用法要点:
- 先调用
PyDict_AddWatcher(callback)注册一个回调,返回的watcher_id是后续操作的句柄; - 再调用
PyDict_Watch(watcher_id, dict)把目标字典标记为被监视;字典每次被修改、清空、克隆或释放时,回调都会收到对应的事件(PyDict_EVENT_ADDED/MODIFIED/DELETED/CLONED/CLEARED/DEALLOCATED); - 回调签名中,
new_value在删除键时为NULL,在清空/释放事件中key和new_value均为NULL; - 不再需要时,用
PyDict_Unwatch解除监视,用PyDict_ClearWatcher注销回调。
从实现侧看,内部通知路径在 Include/internal/pycore_dict.h:每次字典改动会触发 _PyDict_NotifyEvent,它读取 _ma_watcher_tag 的位 0-7 判断是否有监视者挂载,若有则调用 _PyDict_SendEvent 分发给对应的回调。
对依赖 ma_version_tag 的旧扩展(典型场景是自己实现缓存失效检测的模块)而言,迁移路径是:注册一个 watcher,在回调里更新自己的缓存失效标记,取代原先"读版本号 → 与上次比较"的轮询式写法。事件驱动的监视器还能覆盖"字典被克隆"这类旧版本号难以可靠检测的情形。
创建带可变基类的不可变类型:从弃用到 TypeError
背景:Py_TPFLAGS_IMMUTABLETYPE 是什么
Py_TPFLAGS_IMMUTABLETYPE(声明于 Include/object.h)用于标记"类型对象自身不可再修改"的类型。CPython 中静态定义的内置类型(int、dict 等)天然具备这一性质:从源码看,Objects/typeobject.c 中 type_ready 阶段对一切非堆类型自动追加该标志:
/* Historically, all static types were immutable. See bpo-43908 */
if (!(type->tp_flags & Py_TPFLAGS_HEAPTYPE)) {
type_add_flags(type, Py_TPFLAGS_IMMUTABLETYPE);
/* Static types must be immortal */
_Py_SetImmortalUntracked((PyObject *)type);
}
而堆类型(通过 type() 或 PyType_Ready 动态创建的)则只有显式设置该标志时才不可变。这一标志带来一系列约束,例如对不可变类型设置属性会失败:Objects/typeobject.c 的 type_setattro 直接抛出 "cannot set %R attribute of immutable type '%s'",__annotate__、__annotations__ 等 setter 也都有同样的检查(见 Objects/typeobject.c)。
3.14 的收紧规则:基类必须全部不可变
不可变类型的语义要求:若类型 A 不可变,其所有基类也必须不可变,否则 MRO 上会出现"上层认为不可变、下层仍可修改"的矛盾状态。Python 3.12 起(gh-95388)这种组合被弃用,3.14 起(gh-119775)升级为 TypeError。
仓库源码中有两处校验点,覆盖了类型创建与 MRO 更新两条路径:
- 创建时检查。Objects/typeobject.c 中的
check_immutable_bases遍历基类元组,发现任何基类不带Py_TPFLAGS_IMMUTABLETYPE即报错:
static int
check_immutable_bases(const char *type_name, PyObject *bases, int skip_first)
{
Py_ssize_t i = 0;
if (skip_first) {
// When testing the MRO, skip the type itself
i = 1;
}
for (; i < PyTuple_GET_SIZE(bases); i++) {
PyObject *b = PyTuple_GET_ITEM(bases, i);
if (!b) {
return -1;
}
if (!_PyType_HasFeature(b, Py_TPFLAGS_IMMUTABLETYPE)) {
PyErr_Format(
PyExc_TypeError,
"Creating immutable type %s from mutable base %N",
type_name, b
);
return -1;
}
}
return 0;
}
- type_new 入口调用。Objects/typeobject.c 中,当传入标志含
Py_TPFLAGS_IMMUTABLETYPE时立即执行该检查(注释也点明静态类型无此问题,因为只有堆类型才可能是可变的):
/* If this is an immutable type, check if all bases are also immutable.
* (This isn't necessary for static types: those can't have heap bases,
* and only heap types can be mutable.)
*/
if (flags & Py_TPFLAGS_IMMUTABLETYPE) {
if (check_immutable_bases(it.name, bases, 0) < 0) {
goto finally;
}
}
- MRO 变更路径。Objects/typeobject.c 中,类型 MRO 更新(
__mro__赋值)完成后也会重新调用check_immutable_bases(type->tp_name, mro, 1)(skip_first=1,跳过类型自身),因此运行时动态修改 MRO 引入可变基类同样会被拒绝。
对扩展模块作者的实际影响:如果你的 C 扩展通过 PyType_Ready 或堆类型构造创建标记了 Py_TPFLAGS_IMMUTABLETYPE 的类型(例如 type、typing 相关的 typevarobject、sentinelobject 等模块都使用该标志),其基类链上所有堆类型都必须同样携带该标志,否则在 3.14 上会得到 TypeError: Creating immutable type ... from mutable base ...。3.12/3.13 上这类代码只会收到弃用告警,因此跨版本维护的扩展需要按 3.14 的硬错误标准自查类型层次。
迁移建议与兼容性清单
结合上述两项变更,面向 3.14 的扩展模块迁移可以归纳为:
- grep 检查
ma_version_tag:任何直接读取该字段的代码(缓存失效、字典轮询)都需要改写为PyDict_AddWatcher/PyDict_Watch事件驱动方案;编译期上,PyDictObject布局变化意味着偏移量敏感的代码(直接按偏移访问结构体成员)也必须重新编译,不要再依赖旧版头文件的结构尺寸。 - 不可变类型自检:确认所有设置
Py_TPFLAGS_IMMUTABLETYPE的堆类型,其完整 MRO 上不存在可变堆类型基类;注意 3.14 不仅检查创建时,还检查__mro__运行期变更。 - 分版本行为差异:这两项在 3.12 与 3.13 中均只是弃用状态(对应 Misc/NEWS.d/3.12.0a5.rst 中的弃用记录),到 3.14 才变为结构移除 / 运行时
TypeError。同时维护多个 Python 版本的扩展,建议以 3.14 的行为为准编写代码,避免在旧版本上依赖已被移除的字段。
参考路径
- 弃用说明原文:Doc/deprecations/c-api-pending-removal-in-3.14.rst(同目录还有 c-api-pending-removal-in-3.15.rst、c-api-pending-removal-in-3.16.rst 等后续版本的移除清单,以及 soft-deprecations.rst)
- 结构体与监视器 API:Include/cpython/dictobject.h
- 内部字典实现与事件分发:Include/internal/pycore_dict.h
- 类型不可变标志检查逻辑:Objects/typeobject.c
- 3.14 版本说明:Doc/whatsnew/3.14.rst
- 移除记录:Misc/NEWS.d/3.14.0a1.rst
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0630
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00