# Code Objects

代码对象是 Python 虚拟机操作的核心,代码对象(code object)封装了 Python 虚拟机的字节码,我们可以将字节码称为 Python 虚拟机上的汇编语言。

Python 程序由代码块构成的。块是作为单元执行的一段 Python 程序文本。这些都是代码块:模块、函数体和类定义。交互模式下输入的每个命令都是一个块。脚本文件(作为标准输入给解释器或指定解释器的命令行参数的文件)是代码块。脚本命令(在解释器命令行上使用 -c 选项指定的代码)是代码块。传递给内置函数 eval () 和 exec () 的字符串参数是一个代码块。

代码对象包含可运行的字节码指令,这些指令在运行时会改变 Python 虚拟机的状态。给定一个函数,我们可以使用函数的__code__属性访问函数主体的代码对象,例如

>>> def return_author_name():
...     return "code object"
... 
>>> return_author_name.__code__
<code object return_author_name at 0x000001F3C9AB30E0, file "<stdin>", line 1>

对于其它代码块,可以通过 compile 函数来获取该代码块的代码对象。代码对象带有许多字段,这些字段在执行时由解释器循环(interpreter loop)使用。

# 探索代码对象

研究代码对象的一个好方法是编译一个简单的函数,并检查由该函数生成的代码对象。

>>> def fizzbuzz(n):
...     if n % 3 == 0 and n % 5 == 0:
...         return 'FizzBuzz'
...     elif n % 3 == 0:
...         return 'Fizz'
...     elif n % 5 == 0:
...         return 'Buzz'
...     else:
...         return str(n)
...
>>> for attr in dir(fizzbuzz.__code__):
...     if attr.startswith('co_'):
...         print(f"{attr}:\t{getattr(fizzbuzz.__code__, attr)}")
...
co_argcount:    1
co_cellvars:    ()
co_code:        b'|\x00d\x01\x16\x00d\x02k\x02r\x1c|\x00d\x03\x16\x00d\x02k\x02r\x1cd\x04S\x00|\x00d\x01\x16\x00d\x02k\x02r,d\x05S\x00|\x00d\x03\x16\x00d\x02k\x02r<d\x06S\x00t\x00|\x00\x83\x01S\x00d\x00S\x00'
co_consts:      (None, 3, 0, 5, 'FizzBuzz', 'Fizz', 'Buzz')
co_filename:    <stdin>
co_firstlineno: 1
co_flags:       67
co_freevars:    ()
co_kwonlyargcount:      0
co_lnotab:      b'\x00\x01\x18\x01\x04\x01\x0c\x01\x04\x01\x0c\x01\x04\x02'
co_name:        fizzbuzz
co_names:       ('str',)
co_nlocals:     1
co_stacksize:   2
co_varnames:    ('n',)
  1. co_argcount:代码块的参数数量。仅对于函数代码块具有值。它在编译过程中会被设置为代码块中 AST 参数集合的长度。求值循环(evaluation loop)在 set-up 过程中利用这些变量进行代码求值,以进行完整性检查,例如检查所有自变量是否存在以及是否存储局部变量
  2. co_code:它保存了由求值循环执行的字节码指令序列。这些字节码指令序列中的每一个都由一个操作码(opcode)和一个参数(opatg)组成。例如,co.co_code [0] 返回指令的第一个字节 124,该字节对应与 Python 的 LOAD_FAST 操作码(加载局部变量)
  3. co_const:此字段是常量列表,例如代码对象中包含的字符串和数字值。上面的示例显示了 fizzbuzz 函数该字段的内容。此列表中包含的值是代码执行必不可少的,因为他们是 LOAD_CONST 操作码引用的值。字节码指令(如 LOAD_CONST)的操作数参数是此常数列表的索引。fizzbuzz 函数的 co_consts 的值为 (None, 3, 0, 5, ‘FizzBuzz’, ‘Fizz’, ‘Buzz’),我们进行反编译代码对象来对照下
>>> import dis
>>> dis.dis(fizzbuzz) 
  2           0 LOAD_FAST                0 (n) 
              2 LOAD_CONST               1 (3) 
              4 BINARY_MODULO
              6 LOAD_CONST               2 (0) 
              8 COMPARE_OP               2 (==)
             10 POP_JUMP_IF_FALSE       28
             12 LOAD_FAST                0 (n)
             14 LOAD_CONST               3 (5)
             16 BINARY_MODULO
             18 LOAD_CONST               2 (0)
             20 COMPARE_OP               2 (==)
             22 POP_JUMP_IF_FALSE       28
  3          24 LOAD_CONST               4 ('FizzBuzz')
             26 RETURN_VALUE
  4     >>   28 LOAD_FAST                0 (n)
             30 LOAD_CONST               1 (3)
             32 BINARY_MODULO
             34 LOAD_CONST               2 (0)
             36 COMPARE_OP               2 (==)
             38 POP_JUMP_IF_FALSE       44
  5          40 LOAD_CONST               5 ('Fizz')
             42 RETURN_VALUE
  6     >>   44 LOAD_FAST                0 (n)
             46 LOAD_CONST               3 (5)
             48 BINARY_MODULO
             50 LOAD_CONST               2 (0)
             52 COMPARE_OP               2 (==)
             54 POP_JUMP_IF_FALSE       60
  7          56 LOAD_CONST               6 ('Buzz')
             58 RETURN_VALUE
  9     >>   60 LOAD_GLOBAL              0 (str)
             62 LOAD_FAST                0 (n)
             64 CALL_FUNCTION            1
             66 RETURN_VALUE
             68 LOAD_CONST               0 (None)
             70 RETURN_VALUE

在编译过程中,如果函数末尾没有 return 语句,则会自动添加 return None,在偏移量 68 处,字节码指令是针对 None 值的 LOAD_CONST。操作码的参数为 0,而 None 值在常量列表中的索引也确实为 0
4. co_filename:此字段包含文件的名称,该文件包含从中创建代码对象的源代码
5. co_firstlineno:给出了代码对象源代码在文件中所在的第一行的行号。该值在调式代码之类的情景下起着非常重要的作用
6. co_flag:该字段指示代码对象的种类,如,当代码对象是协程的对象时,该标志设置为 0x0080。还有其它标志,例如 CON_NESTED 指示一个代码对象是否嵌套在另一个代码块内,CO_VARARGS 指示一个代码块是否具有变量自变量等,这些标志会影响字节码执行期间求值循环的行为
7. co_lnotab:包含一串用于计算某个字节码偏移量处的指令所对应的源代码行号的字节。例如,dis 函数在计算指令的行号时会使用此功能
8. co_varnames:这是在代码块中局部定义的名称的数量。我们将它与 co_names 对比
9. co_names:这是代码对象内使用的非局部名称的集合。例如下面代码,引用了非局部变量 p

def test_non_local():
    x = p + 1
    return x

查看上述函数代码对象的属性

co_argcount:    0
co_cellvars:    ()
co_code:        b't\x00d\x01\x17\x00}\x00|\x00S\x00'
co_consts:      (None, 1)
co_filename:    <ipython-input-1-b8682549f669>
co_firstlineno: 1
co_flags:       67
co_freevars:    ()
co_kwonlyargcount:      0
co_lnotab:      b'\x00\x01\x08\x01'
co_name:        test_non_local
co_names:       ('p',)
co_nlocals:     1
co_stacksize:   2
co_varnames:    ('x',)

可以看出,co_names 和 co_varnames 之间的区别。co_varnames 引用局部定义的名称,而 co_names 引用非局部定义的名称。需注意,只有在程序执行期间,如果找不到名称 p,才会引发错误,下面是该函数的字节码指令

>>> import dis
>>> dis.dis(test_non_local) 
  2           0 LOAD_GLOBAL              0 (p)
              2 LOAD_CONST               1 (1)
              4 BINARY_ADD
              6 STORE_FAST               0 (x)
  3           8 LOAD_FAST                0 (x)
             10 RETURN_VALUE

要注意,前面例子看到的是 LOAD_FAST,这里出现的是 LOAD_GLOBAL 指令。
10. con_nlocals:代表代码对象使用的局部名称的数量。上面代码中,唯一使用的局部变量是 x,因此,该函数的代码对象的此值为 1
11. co_stacksize:Python 虚拟机是基于堆栈的计算机,即用于求值和求值结果的值可从执行栈读取或写入执行栈。此 co_stacksize 值是代码块执行期间任何时候求值堆栈上存在的最大项目数
12. co_freevars:该字段是在代码块内定义的自由变量的集合。该字段与闭包,或者嵌套函数相关。自由变量是指在一个块内使用但未在该块内定义的变量。这不适用于全局变量。自由变量的概念比如下面代码

def f(*args):
    x = 1
    def g():
        n = x
    return g

相对 f 函数的代码对象,co_freevars 字段为空,而 g 函数的代码对象包含 x 值。自由变量与 cell 变量密切相关
13. co_cellvars:该字段是名称的集合,在执行代码对象期间必须为其创建 cell 存储对象。如上 f 函数,代码对象的 co_cellvars 字段仅包含名称 x,而嵌套函数的代码对象的名称为空。回想一下有关自由变量的问题,嵌套函数的代码对象的 co_freevars 集合仅包含这个名称 x。这说明了 cell 变量和自由变量之间的关系,嵌套范围内的自由变量是封闭范围内的 cell 变量。在这个代码对象执行期间,将创建特殊的 cell 对象以将值存储在此 cell 变量集合中。之所以这样,是因为该字段中的每个值都被嵌套的代码对象使用,它们的生存时间可能会超过封闭代码对象的生存时间,因此,这些值必须存储在代码对象执行完成后不会释放的其它位置

# 关于字节码 co_code 的细节

代码对象的实际虚拟机指令字节码包含在代码的 co_code 字段中。例如,fizzbuzz 函数的字节码

b'|\x00d\x01\x16\x00d\x02k\x02r\x1c|\x00d\x03\x16\x00d\x02k\x02r\x1cd\x04S\x00|\x00d\x01\x16\x00d\x02k\x02r,d\x05S\x00|\x00d\x03\x16\x00d\x02k\x02r<d\x06S\x00t\x00|\x00\x83\x01S\x00d\x00S\x00'

我们可以通过 dis module 转换为可阅读的格式

2           0 LOAD_FAST                0 (n)
              2 LOAD_CONST               1 (3)
              4 BINARY_MODULO
              6 LOAD_CONST               2 (0)
              8 COMPARE_OP               2 (==)
             10 POP_JUMP_IF_FALSE       28
             12 LOAD_FAST                0 (n)
             14 LOAD_CONST               3 (5)
             16 BINARY_MODULO
             18 LOAD_CONST               2 (0)
             20 COMPARE_OP               2 (==)
             22 POP_JUMP_IF_FALSE       28
  3          24 LOAD_CONST               4 ('FizzBuzz')
             26 RETURN_VALUE
  4     >>   28 LOAD_FAST                0 (n)
             30 LOAD_CONST               1 (3)
             32 BINARY_MODULO
             34 LOAD_CONST               2 (0)
             36 COMPARE_OP               2 (==)
             38 POP_JUMP_IF_FALSE       44
  5          40 LOAD_CONST               5 ('Fizz')
             42 RETURN_VALUE
  6     >>   44 LOAD_FAST                0 (n)
             46 LOAD_CONST               3 (5)
             48 BINARY_MODULO
             50 LOAD_CONST               2 (0)
             52 COMPARE_OP               2 (==)
             54 POP_JUMP_IF_FALSE       60
  7          56 LOAD_CONST               6 ('Buzz')
             58 RETURN_VALUE
  9     >>   60 LOAD_GLOBAL              0 (str)
             62 LOAD_FAST                0 (n)
             64 CALL_FUNCTION            1
             66 RETURN_VALUE
             68 LOAD_CONST               0 (None)
             70 RETURN_VALUE

输出的第一列显示该指令的行号。多个指令可以映射到同一行号。使用来自代码对象的 co_lnotab 字段的信息来计算该值。第二列是给定指令与字节码开头的偏移量。假设字节码字符串包含在数组中,则此值是可以在该数组中找到给定指令的索引。第三列是实际的人类可读指令操作码,可以在 Include/opcode.h 文件中找到所有操作码。第四列是指令的参数。

第一条 LOAD_FAST 指令使用参数 0,此值是 co_varnames 数组的索引。最后一列是参数的值,由 dis 模块提供,以方便阅读。一些参数不采用显式参数。请注意,BINARY_MODULE 和 RETURN_VALUE 指令没有任何显式参数。Python 虚拟机是基于堆栈的计算机,因此这些指令从堆栈顶部读取值。

字节码指令的大小为两个字节,一个字节用于操作码,第二个字节用于操作码的参数。如果操作码不带参数,则将第二个参数字节清零。

# 嵌套的代码对象

def f():
    print(c)
    a = 1
    b = 3
    def g():
        print(a+b)
        c=2
        def h():
            print(a+b+c)

被编译后进行反汇编,我们能得到以下字节码

1          0 LOAD_CONST               0 (<code object f at 0x000002D7ADE8E540, file "example.py", line 1>)
            2 LOAD_CONST               1 ('f')
            4 MAKE_FUNCTION            0
            6 STORE_NAME               0 (f)
            8 LOAD_CONST               2 (None)
            10 RETURN_VALUE

字节偏移量为 0 的指令将加载一个代码对象,该对象存储为名称 f,使用 MAKE_FUNCTION 指令的函数定义。

模块代码对象的属性

co_argcount:    0
co_cellvars:    ()
co_code:        b'd\x00d\x01\x84\x00Z\x00d\x02S\x00'
co_consts:      (<code object f at 0x000002D7ADE8E540, file "example.py", line 1>, 'f', None)
co_filename:    example.py
co_firstlineno: 1
co_flags:       64
co_freevars:    ()
co_kwonlyargcount:      0
co_lnotab:      b''
co_name:        <module>
co_names:       ('f',)
co_nlocals:     0
co_stacksize:   2
co_varnames:    ()

就像预期的那样,模块代码对象与参数相关的字段(co_argcount,co_kwonlyargcount)全为 0。co_code 字段包含字节码指令,co_consts 字段是一个有趣的字段。字段中的常量是代码对象,名称为 f 和 None,其中,代码对象是模块中函数 f 对应的代码对象,值 "f" 是函数的名称,"None" 是函数的返回值。Python 编译器会向没有返回值的代码对象添加 return None 语句。

在模块编译期间,实际上并未创建函数对象,我们拥有的只是代码对象,函数实际上是代码对象执行期间创建的。如果检查以下 f 的代码对象的属性,会发现,实际上它也由其它代码对象(内部嵌套函数 g 对应的代码对象)组成。

co_argcount:    0
co_cellvars:    ('a', 'b')
co_code:        b't\x00t\x01\x83\x01\x01\x00d\x01\x89\x00d\x02\x89\x01\x87\x00\x87\x01f\x02d\x03d\x04\x84\x08}\x00d\x00S\x00'
co_consts:      (None, 1, 3, <code object g at 0x000002D7ADE8E6F0, file "example.py", line 5>, 'f.<locals>.g')
co_filename:    example.py
co_firstlineno: 1
co_flags:       3
co_freevars:    ()
co_kwonlyargcount:      0
co_lnotab:      b'\x00\x01\x08\x01\x04\x01\x04\x01'
co_name:        f
co_names:       ('print', 'c')
co_nlocals:     1
co_stacksize:   3
co_varnames:    ('g',)

仅在执行代码对象时才创建 g 的函数对象。

# 虚拟机中 Code Object 的实现

VM 中代码对象的实现与其在 Python 中看到的对象属性非常相似。像大多数内置类型一样,存在一个 code 类型和一个 PyCodeObject 结构体定义了代码对象的类型和实例。

typedef struct {
    PyObject_HEAD
    int co_argcount;            /* #arguments, except *args */
    int co_kwonlyargcount;      /* #keyword only arguments */
    int co_nlocals;             /* #local variables */
    int co_stacksize;           /* #entries needed for evaluation stack */
    int co_flags;               /* CO_..., see below */
    int co_firstlineno;         /* first source line number */
    PyObject *co_code;          /* instruction opcodes */
    PyObject *co_consts;        /* list (constants used) */
    PyObject *co_names;         /* list of strings (names used) */
    PyObject *co_varnames;      /* tuple of strings (local variable names) */
    PyObject *co_freevars;      /* tuple of strings (free variable names) */
    PyObject *co_cellvars;      /* tuple of strings (cell variable names) */
    /* The rest aren't used in either hash or comparisons, except for co_name,
       used in both. This is done to preserve the name and line number
       for tracebacks and debuggers; otherwise, constant de-duplication
       would collapse identical functions/lambdas defined on different lines.
    */
    Py_ssize_t *co_cell2arg;    /* Maps cell vars which are arguments. */
    PyObject *co_filename;      /* unicode (where it was loaded from) */
    PyObject *co_name;          /* unicode (name, for reference) */
    PyObject *co_lnotab;        /* string (encoding addr<->lineno mapping) See
                                   Objects/lnotab_notes.txt for details. */
    void *co_zombieframe;       /* for optimization only (see frameobject.c) */
    PyObject *co_weakreflist;   /* to support weakrefs to code objects */
    /* Scratch space for extra data relating to the code object.
       Type is a void* to keep the format private in codeobject.c to force
       people to go through the proper APIs. */
    void *co_extra;
} PyCodeObject;

除了 co_stacksize、co_flags、co_cell2arg、co_zombieframe、co_weakreflist 和 co_extra 外,这些字段几乎都与在 Python 中的代码对象中找到的字段相同。co_zombieframe 是为了优化而存在的字段。这保留了用作上下文执行代码对象的 frame 对象的引用。当这样的代码对象被重新执行时,它被用过执行 frame,以防止为另一个 frame 对象分配额外的内存带来开销。