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
指令。
参考: