CPython 字节码演进:DELETE_ATTR 退场,属性删除统一由 PUSH_NULL + STORE_ATTR 承载
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
本文基于 CPython 官方变更记录 2026-03-20-16-27-15.gh-issue-145855.3yaHO4.rst,深入剖析 Python 3.16 中一条关键的字节码级重构:DELETE_ATTR操作码被彻底移除,改为由PUSH_NULL与STORE_ATTR的组合指令序列来承载del obj.attr语义。读者将从中理解 CPython 编译器(codegen)如何生成删除指令、STORE_ATTR如何区分"赋值"与"删除"两种语义、这一改动对字节码反汇编输出与 .pyc 文件兼容性的影响,以及它与DELETE_NAME、DELETE_GLOBAL同系列改动的内在一致性。
变更概览:一条新闻条目背后的整条改动链
官方 NEWS 条目只有一句话:
The
DELETE_ATTRopcode is now replaced withPUSH_NULL; STORE_ATTR.
翻译过来即:DELETE_ATTR操作码已被PUSH_NULL; STORE_ATTR取代。这句话看似简单,实际牵动了 CPython 源码中从语法树编译、操作码定义、内联缓存特化到 .pyc 魔数编号的一整条链路。仓库中 pycore_magic_number.h 的注释清楚地记录了这次改动对应的字节码版本号:
Python 3.16a1 3704 (Replace DELETE_ATTR with PUSH_NULL; STORE_ATTR)这意味着任何包含del obj.attr语句的 Python 代码,编译产生的 .pyc 字节码布局都与旧版本不兼容——这也是为什么这次改动必须同步 bump Python 字节码魔数(当前仓库中的 PYC_MAGIC_NUMBER 已推进到 3705)。
值得注意的是,这并非孤立的一次性改动。同一版本中还有两条性质完全相同的变更:
Python 3.16a1 3702 (Replace DELETE_NAME with PUSH_NULL; STORE_NAME)Python 3.16a1 3703 (Replace DELETE_GLOBAL with PUSH_NULL; STORE_GLOBAL)
也就是说,CPython 团队正在系统性地将"删除语义"(删除局部变量、删除全局变量、删除对象属性)从独立的DELETE_*操作码中剥离,统一复用现有的STORE_*指令,通过压入一个特殊的空值标记(PUSH_NULL)来指示"本次存储操作实际执行删除"。DELETE_ATTR的移除正是这条演进路径上的第三站。
编译器如何生成新指令序列:codegen 侧的改动
要理解这条 NEWS 的实际落地,最直接的方式是查看 CPython 的字节码生成器(code generator)。在 Python/codegen.c 中,Attribute表达式节点的三种上下文被分别处理:
switch (e->v.Attribute.ctx) { case Load: VISIT(c, expr, e->v.Attribute.value); ADDOP_NAME(c, loc, LOAD_ATTR, e->v.Attribute.attr, names); break; case Store: VISIT(c, expr, e->v.Attribute.value); ADDOP_NAME(c, loc, STORE_ATTR, e->v.Attribute.attr, names); break; case Del: ADDOP(c, loc, PUSH_NULL); VISIT(c, expr, e->v.Attribute.value); ADDOP_NAME(c, loc, STORE_ATTR, e->v.Attribute.attr, names); break; }三种上下文的行为对比如下:
| 上下文 | 生成指令序列 | 栈效果 |
|---|---|---|
读取属性x = obj.attr | 求值对象 →LOAD_ATTR attr | 弹出对象,压入属性值 |
写入属性obj.attr = v | 求值对象 → 求值值 →STORE_ATTR attr | 弹出值与对象 |
删除属性del obj.attr(改动后) | PUSH_NULL→ 求值对象 →STORE_ATTR attr | 弹出空值标记与对象 |
可以看到,Del分支不再发射独立的DELETE_ATTR指令,而是先压入PUSH_NULL产生的空值标记(该标记在栈上扮演"待存储的值"角色),随后照常求值属性所属对象,最后交给STORE_ATTR。这样删除与赋值共用同一条指令,仅在"待写入的值"是否为 null 上做出区分。
STORE_ATTR 的双重语义:赋值与删除如何分流
STORE_ATTR被设计为既能写入属性、也能删除属性的通用指令,其底层行为由 Python/bytecodes.c 中的核心实现决定:
op(_STORE_ATTR, (v, owner --)) { PyObject *name = GETITEM(FRAME_CO_NAMES, oparg); int err = PyObject_SetAttr(PyStackRef_AsPyObjectBorrow(owner), name, PyStackRef_AsPyObjectBorrow(v)); PyStackRef_CLOSE(owner); PyStackRef_XCLOSE(v); ERROR_IF(err); }这里的关键在于 CPython 对象层 Objects/object.c 中PyObject_SetAttr()的既有约定:当value参数为 NULL 时,函数不会执行"写入",而是沿类型系统走删除路径(该函数在 Objects/object.c 中显式检查"以 NULL 值调用时不允许已有异常挂起"的约束,从侧面印证了 NULL 值是受支持的合法调用形态)。因此:
- 常规赋值
obj.attr = v:v为真实对象引用,STORE_ATTR调用PyObject_SetAttr(owner, name, v)完成写入; - 删除属性
del obj.attr:PUSH_NULL压入的是空值标记,STORE_ATTR从栈上取到的v即空值,等价于以 NULL 调用PyObject_SetAttr,从而触发删除语义。
从源码结构可以推断,PUSH_NULL压入的正是 CPython 3.12+ 引入的"空栈引用(null stack reference)"机制——它不指向任何真实对象,仅作为占位标记参与栈上槽位的计数,而PyStackRef_IsNull(v)之类的检查即可识别该标记。
删除路径同样参与特化(specialization)
STORE_ATTR并不是一条孤立指令,而是一个指令族(family)的"未特化入口"。在 Python/bytecodes.c 中定义了该族及其三个特化版本:
family(STORE_ATTR, INLINE_CACHE_ENTRIES_STORE_ATTR) = { STORE_ATTR_INSTANCE_VALUE, STORE_ATTR_SLOT, STORE_ATTR_WITH_HINT, };其宏展开形式(Python/bytecodes.c)为:
macro(STORE_ATTR) = _SPECIALIZE_STORE_ATTR + unused/3 + _STORE_ATTR;即在执行前先经过_SPECIALIZE_STORE_ATTR的自适应特化决策(依据内联缓存中的计数器触发),再回退到通用实现或跳转到STORE_ATTR_INSTANCE_VALUE(实例字典属性)、STORE_ATTR_SLOT(__slots__槽位属性)、STORE_ATTR_WITH_HINT(带版本提示的属性)等快速路径。这些特化指令的编号、格式与标志位可以在 Include/opcode_ids.h 和 Include/internal/pycore_opcode_metadata.h 中交叉验证,每个特化版本都声明了HAS_DEOPT_FLAG、HAS_ESCAPES_FLAG等元数据,并映射回STORE_ATTR作为其反特化(deopt)目标。
值得注意的细节是:_SPECIALIZE_STORE_ATTR中有一个if (!PyStackRef_IsNull(v))的守卫(Python/bytecodes.c),从源码结构看,这意味着当栈顶是PUSH_NULL压入的空值标记(即删除操作)时,特化逻辑不会贸然按"写入实例属性"的假设去改写指令——删除操作更倾向于走通用路径,从而保证del obj.attr在语义正确的前提下不被错误加速。这正是"删除与赋值共用指令"后,CPython 必须额外考虑的语义分歧点。
对开发者可观察行为的影响
反汇编输出变化
对于 Python 3.16 及后续版本,dis模块输出的字节码不再出现DELETE_ATTR。以del obj.attr为例,改动前后的典型反汇编对比为:
# 改动前(3.15 及更早) LOAD_NAME obj DELETE_ATTR attr # 改动后(3.16+,由 Python/codegen.c 生成) PUSH_NULL LOAD_NAME obj STORE_ATTR attrDELETE_ATTR从此不再存在于操作码表与 Include/opcode_ids.h 的编号体系中,任何依赖该操作码做静态分析、字节码改写或反编译的工具(如基于opcode模块的第三方库、自定义 tracer)都必须适配新的PUSH_NULL; STORE_ATTR组合。
.pyc 文件兼容性
由于指令序列发生了结构性变化,该改动同步提升了字节码魔数(magic number)。根据 pycore_magic_number.h 的记录,DELETE_ATTR移除对应魔数 3704,此后任何更新都会使旧版本解释器无法直接加载新 .pyc(表现为ValueError: bad marshal data或触发重新编译)。这一机制保证了解释器版本与字节码格式的严格绑定,避免新旧指令语义混用。
删除操作的行为语义保持不变
对普通 Python 开发者而言,del obj.attr的运行时语义没有变化:仍然会触发__delattr__/tp_setattro(..., NULL)删除路径,删除不存在的属性依旧抛出AttributeError,__slots__属性依旧遵循 slot 描述符的删除规则。改动纯粹发生在"字节码表示层",属于解释器内部的指令集精简。
设计动机与演进脉络
从 3.16 的三条连续变更(DELETE_NAME→STORE_NAME、DELETE_GLOBAL→STORE_GLOBAL、DELETE_ATTR→STORE_ATTR)可以看出 CPython 指令集设计的整体取向:
- 指令集收敛:删除语义不再需要独立的操作码家族,而是复用
STORE_*指令 + 空值标记表达,减少操作码数量与解释器分发的指令种类; - 特化体系统一:
STORE_ATTR已有的内联缓存与特化家族(实例值、槽位、版本提示)无需为删除单独复制一套,属性访问的热路径基础设施可以同时服务写入与删除; - 与既有栈机约定对齐:
PyObject_SetAttr的 NULL value 语义早已存在,字节码层面的改动本质上是把这一 C API 约定显式化到指令流中。
对于希望深入源码的读者,推荐按以下路径继续追踪:从 Python/codegen.c 看指令生成,到 Python/bytecodes.c 看指令定义与特化结构,再到 Include/internal/pycore_opcode_metadata.h 看指令元数据,最后以 Include/internal/pycore_magic_number.h 的魔数注释收尾——这一条完整的链路就是本次 NEWS 条目的全部工程含义。
小结
DELETE_ATTR被PUSH_NULL; STORE_ATTR取代,是 CPython 3.16 在字节码层面的一次"减法式"重构:删除属性不再拥有专属指令,而是通过空值标记复用STORE_ATTR的通用执行路径与特化体系。它带来的直接变化包括反汇编输出形态的改变、.pyc 魔数 3704 的引入,以及指令集的进一步收敛,而del obj.attr的运行时行为与异常语义保持不变。理解这一改动,有助于开发者更准确地把握 CPython 字节码演进方向,也为从事字节码分析、JIT 与内联缓存研究的读者提供了一个高信息密度的观察样本。
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考