Python 底层原理探析:CodeObject
初见 CodeObject
先来看这样一段代码:
def func():
pass
code = func.__code__
print(type(func))
print(code)
运行结果:
<class 'function'>
<code object func at 0x00000268F04323F0, file "<stdin>", line 1>
在 Python 中,函数是一个 function object ,Python 编译器会生成一个与之对应的 code object,保存到函数的 __code__ 属性中。
code object 的属性
code object 是一个不可变的对象,包含了函数的字节码和其他信息。我们可以通过 dir() 函数来查看它的属性:
print(dir(code))
运行结果:
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '_co_code_adaptive', '_varname_from_oparg', 'co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_exceptiontable', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_kwonlyargcount', 'co_lines', 'co_linetable', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_positions', 'co_posonlyargcount', 'co_qualname', 'co_stacksize', 'co_varnames', 'replace']
下面将其中的部分属性分为几组进行介绍:
字节码存储: co_code 属性
含义解释
print(code.co_code)
运行结果:
b'\x97\x00y\x00'
官网上的解释是:字符串形式的原始字节码。用二进制来表示,可以通俗地理解为“汇编”。
新版本特性
我们可以使用 dis 模块来查看 func 函数的人类可读形式字节码:
import dis
dis.dis(func)
运行结果:
# python <= 3.10
2 0 LOAD_CONST 0 (None)
2 RETURN_VALUE
# python = 3.11
1 0 RESUME 0
2 2 LOAD_CONST 0 (None)
4 RETURN_VALUE
# python >= 3.12
1 0 RESUME 0
2 2 RETURN_CONST 0 (None)
- 在 Python 3.10 版本中,首先通过
LOAD_CONST将None压入栈,然后通过RETURN_VALUE从栈顶弹出并返回。 - 在 Python 3.11 版本中,增加了
RESUME指令。这是一个空操作,用于执行内部追踪、调试和优化检查。但此时没有新指令引入,故仍然需要压栈。 - 而在 Python 3.12 版本中,增加了
RETURN_CONST指令。这个指令是为了支持函数返回常量值的操作,使得函数的返回值可以直接从常量池中获取,而不需要先压入栈中。
辅助数据存储
co_name co_filename co_lnotab co_lines 这几个属性与程序的实际运行没有直接的关系,主要作为辅助数据。
含义解释
print(code.co_name)
print(code.co_filename)
print(code.co_lnotab) # 自 3.12 版本弃用,可能在 3.15 版本中删除
运行结果如下:
func
<stdin>
b'\x02\x01'
co_name 属性:官方释义为“定义此代码对象的名称”,一般为所定义的函数的名称。
co_filename 属性:函数定义所在的文件名。
co_lnotab 属性:官方释义为“编码的行号到字节码索引的映射”,也就是以二进制形式保存每个字节码对应的源代码行数。注意这里是通过一个压缩算法进行保存,在解释器的源码中有对这个算法的详细解释。
新版本特性
co_lines 属性:自 3.10 版本后引入,作为 co_lnotab 的替代。官方释义为“返回一个产生有关 bytecode 的连续范围的信息的迭代器。 其产生的每一项都是一个 (start, end, lineno) 元组”,相比于 co_lnotab 属性,co_lines 属性使得解析源代码行号变得更加直观和高效。我们可以使用 for 循环来遍历这个迭代器,并结合 dis 模块来查看函数的字节码:
# python >= 3.12
for line_info in code.co_lines():
print(line_info)
dis.dis(func)
运行结果如下:
(0, 2, 1)
(2, 4, 2)
1 0 RESUME 0
2 2 RETURN_CONST 0 (None)
其中每个元组 (start, end, lineno) 表示从字节码偏移量 start 到 end 的范围对应于源代码中的行号 lineno。例如,(0, 2, 1) 表示字节码偏移量从 0 到 2 的部分对应于源代码的第 1 行;(2, 4, 2) 则表示偏移量从 2 到 4 的字节码对应于源代码的第 2 行。结合 dis.dis(func) 输出的结果可以看出,每一行字节码指令的偏移量与源代码的行号之间保持了清晰的映射关系。这使得调试器可以准确地指出当前执行的是哪一行源代码,也方便了性能分析和代码覆盖率工具对程序行为的追踪。相比旧的 co_lnotab 编码方式,co_lines 提供的接口更加直观、安全,并且易于使用。
运行时栈相关
含义解释
co_flags co_stacksize 属性与函数的运行时栈相关。
print(code.co_flags)
print(code.co_stacksize)
运行结果如下:
# python <= 3.11
3
1
# python >= 3.12
3
0
-
co_flags属性:表示代码对象的一些编译时标志,是 Python 一些 flags (详情见此处)的位映射(bitmap)。 -
co_stacksize属性:表示“所需的虚拟机堆栈空间”,即函数执行时所需的最大栈深度。结合前文所述,func函数在Python 3.12 版本中执行时不需要额外的栈空间,所以co_stacksize属性的值为 0。
函数输入参数数量相关
此类属性决定了函数处理输入参数的方式,也是 python 进行函数重载的基础。主要包括 co_argcount co_kwonlyargcount 以及 co_posonlyargcount 。
co_argcount属性:参数数量(不包括仅关键字参数、* 或 ** 参数)。co_posonlyargcount属性:仅限位置参数的数量。co_kwonlyargcount属性:仅限关键字参数的数量(不包括 ** 参数)。
Python 函数参数类型的辨析见 Python 编程技术:函数的参数。
名称相关
co_nlocals co_varnames co_names co_cellvars co_freevars co_consts 这几个属性与函数的名称相关。
显而易见,在大多数编程语言的函数中,会涉及多种名称,包括参数名以及函数内部变量的名称。这就对字节码的编译和运行时的名称解析提出了要求。在 Python 中,函数中的各种名称会被分类整理,存储在不同的名称表中,然后在生成字节码时,通过索引来引用这些名称,而不是直接使用字符串名,这样可以提高执行效率。下面铜鼓两个实例来说明:
def f(a):
import math as m
b = a.attrs
c = 1
return b
code = f.__code__
import dis
dis.dis(f)
print(f"nlocals: {code.co_nlocals}")
print(f"varnames: {code.co_varnames}")
print(f"names: {code.co_names}")
print(f"consts: {code.co_consts}")
运行结果:
1 0 RESUME 0
2 2 LOAD_CONST 1 (0)
4 LOAD_CONST 0 (None)
6 IMPORT_NAME 0 (math)
8 STORE_FAST 1 (m)
3 10 LOAD_FAST 0 (a)
12 LOAD_ATTR 1 (attrs)
22 STORE_FAST 2 (b)
4 24 LOAD_CONST 2 (1)
26 STORE_FAST 3 (c)
5 28 LOAD_FAST 2 (b)
30 RETURN_VALUE
nlocals: 4
varnames: ('a', 'm', 'b', 'c')
names: ('math', 'attrs')
consts: (None, 0, 1)
co_nlocals属性:局部变量的数量。co_varnames属性:参数名和局部变量的元组。其长度一般为co_nlocals属性的值。co_names属性:除参数和函数局部变量之外的名称元组。co_consts属性:字节码中使用的常量元组。
def f():
d = {}
def g():
d["a"] = 1
return g
code = f.__code__
import dis
dis.dis(f)
print(f"cellvars: {code.co_cellvars}")
print(f"freevars: {code.co_freevars}")
运行结果:
0 MAKE_CELL 1 (d)
1 2 RESUME 0
2 4 BUILD_MAP 0
6 STORE_DEREF 1 (d)
3 8 LOAD_CLOSURE 1 (d)
10 BUILD_TUPLE 1
12 LOAD_CONST 1 (<code object g at ...>)
14 MAKE_FUNCTION 8 (closure)
16 STORE_FAST 0 (g)
5 18 LOAD_FAST 0 (g)
20 RETURN_VALUE
Disassembly of <code object g at ...>:
0 COPY_FREE_VARS 1
3 2 RESUME 0
4 4 LOAD_CONST 1 (1)
6 LOAD_DEREF 0 (d)
8 LOAD_CONST 2 ('a')
10 STORE_SUBSCR
14 LOAD_CONST 0 (None)
16 RETURN_VALUE
cellvars: ('d',)
freevars: ()
-
co_cellvars属性:cell variables 的名称的元组(通过包含作用域引用)。换言之,是一个包含当前作用域中被内部嵌套函数引用的局部变量名称的元组。在上面的例子中,d被g函数引用,所以co_cellvars的值为('d',)。 -
co_freevars属性:free variables 的名称的元组(通过函数闭包引用)。换言之,是一个包含当前作用域中来自外部作用域的变量名称的元组。在上面的例子中,d在f函数的作用域,f中并没有来自外层作用域的变量,所以co_freevars的值为()。如果将上述代码中的codef = f.__code__改为codef = f().__code__,co_freevars的值就会变为('d',)。
co_freevars 可以看作是对 co_cellvars 的引用。
def f():
d = {}
def g():
#d["a"] = 1
pass
return g
code = f.__code__
import dis
dis.dis(f)
print(f"cellvars: {code.co_cellvars}")
print(f"freevars: {code.co_freevars}")
运行结果:
1 0 RESUME 0
2 2 BUILD_MAP 0
4 STORE_FAST 0 (d)
3 6 LOAD_CONST 1 (<code object g at ...>)
8 MAKE_FUNCTION 0
10 STORE_FAST 1 (g)
6 12 LOAD_FAST 1 (g)
14 RETURN_VALUE
Disassembly of <code object g at ...>:
3 0 RESUME 0
5 2 LOAD_CONST 0 (None)
4 RETURN_VALUE
cellvars: ()
freevars: ()
可以看到,当注释掉 d["a"] = 1 这一行时,co_cellvars 和 co_freevars 的值都变成了 ()。这是因为 g 函数不再引用 d 变量,所以 d 不再是一个 cell variable。并且 dis.dis(f) 的输出中也没有 MAKE_CELL 指令与 LOAD_DEREF 指令。
参考: