@contextmanager def timer(): start = time.time() yield end = time.time() print(end-start) 这个编译成字节码对象时,将timer代码对象+timer函数名组装成未装饰的函数对象,调用装饰器函数contextmanager(timer),返回一个装饰器函数(自动添加了__enter和__exit) STORE_NAME 3(timer) 符号表co_names的timer不变,改变运行时的命名空间字典(globals/locals),名字和新对象绑定 这是什么原理?编译期生成的字节码能重新绑定运行期间的对象?这能在字节码里面体现出来,有指令重新绑定过程?
字节码里并不会“重新编译”什么,它只是执行了一条普通的 STORE_NAME / STORE_FAST 指令,把一个全新的对象重新写进当前作用域的 dict / fast-locals 槽里。编译期生成的符号表(co_names、co_varnames …)在运行期是只读的;真正可变的是运行时命名空间(PyDictObject 或 fast-locals 数组),字节码里对名字的“重新绑定”就是一次普通的键值覆写。 下面把整个过程拆成三段来说明: 编译期到底生成了哪些“不可变”的东西 • 对源代码里的 def timer(): ...,编译器生成 – 一个 PyCodeObject,里边有字节码、co_names、co_varnames 等; – 一个 PyFunctionObject(记作 f0),它的 func_code 指向上面的 code,func_globals 指向当时的 globals; – 然后把 f0 存进当前 locals / globals 的键 "timer" 中(对应字节码 STORE_NAME / STORE_GLOBAL / STORE_FAST,视作用域而定)。 • 对 @contextmanager 这一行,编译器只看到语法糖,于是生成了另一条“装饰器协议”字节码: 复制 LOAD_NAME contextmanager # 把装饰器对象推到栈顶 LOAD_NAME timer # 把刚才存进去的 f0 再取出来 CALL_FUNCTION 1 # 执行 contextmanager(f0) 得到 f1 STORE_NAME timer # 把 f1 重新写回名字 'timer' 注意:这里 co_names 里 "timer" 这个字符串的索引在整个 PyCodeObject 里不会变,变的是运行时 dict 里 "timer" 这个键对应的值。 运行期到底发生了什么 • 模块首次 import 时,Python 虚拟机顺序执行字节码: – 先执行 def 的 BUILD_FUNCTION/STORE_NAME,此时 locals()['timer'] == f0; – 再执行装饰器指令序列,contextmanager(f0) 返回一个新的 helper 对象(内部包装了 f0,并添加了 enter/exit); – 最后执行 STORE_NAME 再次把 locals()['timer'] 改成这个 helper。 • 这条 STORE_NAME 与给普通变量赋值的字节码一模一样,没有任何特殊“重新绑定”指令;只不过这次右值恰好是另一个完全不同的对象。 字节码层面能否看出“重绑定”? 拿 3.11 举例,把模块编译成字节码后反汇编: 复制 0 LOAD_NAME 0 (contextmanager) 2 LOAD_NAME 1 (timer) # 这里 timer 还是 f0 4 CALL 1 6 STORE_NAME 1 (timer) # 执行后 timer 变成 f1 索引 1 在 co_names 中恒定是字符串 "timer";指令本身只是“把栈顶存到名字 1 对应的位置”。 因此,“重新绑定”完全体现在运行时数据结构的更新,而字节码里只是一条普通的 STORE_NAME(或 STORE_FAST/STORE_GLOBAL,视作用域而定)