5. Code Objects

代码对象

在本文的这一部分中,我们探索代码对象。 代码对象是 python 虚拟机操作的核心。 代码对象(code object)封装了 python 虚拟机的字节码, 我们可以将字节码称为 python 虚拟机上的汇编语言。

顾名思义,代码对象代表已编译的可执行 python 代码。 在讨论 python 源代码的编译之前,我们已经看到了代码对象。 每当编译 python 代码块时,都会生成代码对象。Python 文档对代码对象进行了很好的描述。

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

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

代码清单5.1: 访问函数的代码对象
def return_author_name():
    return "obi Ike-Nwosu"

>>> return_author_name.__code__
<code object return_author_name at 0x00000257E0D18C00, file "<ipython-input-1-125b1cac33be>", line 1>

对于其他代码块,可以通过 compile 函数来获取该代码块的代码对象。 代码对象带有许多字段,这些字段在执行时由解释器循环(interpreter loop)使用,我们将在以下各节中介绍其中一些字段。

5.1 探索代码对象

研究代码对象的一个好方法是编译一个简单的函数,并检查由该函数生成的代码对象。 我们将简单的 fizzbuzz 函数用作例子,如清单5.2所示。

代码清单5.2: fizzbuzz 函数的代码对象
>>> 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',)

输出的字段似乎几乎是不言自明的,除了 co_lnotabco_code 字段似乎包含乱码。 我们来继续说明这些字段及其对 python 虚拟机的重要性。

  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')

    ,我们拿它与下面的反汇编代码对象进行对照:

    代码清单5.3: fizzbuzz 函数的字节码指令
    >>> 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 值在 LOAD_CONST 指令实际加载的常量列表中的索引也为0。

  4. co_filename:顾名思义,此字段包含文件的名称,该文件包含从中创建代码对象的源代码。

  5. co_firstlineno:这给出了代码对象源代码在文件中所在的第一行的行号。 该值在诸如调试代码之类的情景下中起着非常重要的作用。

  6. co_flag:该字段指示代码对象的种类。 例如,当代码对象是协程的对象时,该标志设置为 0x0080。 还有其他标志,例如CO_NESTED 指示一个代码对象是否嵌套在另一个代码块内,CO_VARARGS 指示一个代码块是否具有变量自变量,等等。 这些标志会影响字节码执行期间求值循环的行为。

  7. co_lnotab:包含一串用于计算某个字节码偏移量处的指令所对应的源代码行号的字节。 例如,dis 函数在计算指令的行号时会使用此功能。

  8. co_varnames:这是在代码块中局部定义的名称的数量。 我们将它与co_names 对比。

  9. co_names:这是代码对象内使用的非局部名称的集合。 例如,清单5.4中的代码段引用了非局部变量p

    代码清单5.4: 局部与非局部名称
         def test_non_local():
             x = p + 1
             return x

    清单5.5中显示了上述函数代码对象的属性

    代码清单5.5
    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_namesco_varnames 之间的区别。 co_varnames 引用局部定义的名称,而 co_names 引用非局部定义的名称。 请注意,只有在程序执行期间,如果找不到名称 p,才会引发错误。 清单5.6中显示了清单5.4中该函数的字节码指令。

    代码清单5.6
      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 指令。 稍后,当我们讨论求值循环时,我们将讨论求值循环所执行的优化,该优化使用了LOAD_FAST 指令,正如名称所暗示的那样。

  10. co_nlocals:代表代码对象使用的局部名称的数量。 在清单5.4的示例中,唯一使用的局部变量是 x,因此该函数的代码对象的此值为1。

  11. co_stacksize:python 虚拟机是基于堆栈的计算机,即用于求值和求值结果的值可从执行栈读取或写入执行栈。 此 co_stacksize 值是代码块执行期间任何时候求值堆栈上存在的最大项目数。

  12. co_freevars:该字段是在代码块内定义的自由变量的集合。 该字段与形成闭包的嵌套函数最相关。 自由变量是在一个块内使用但未在该块内定义的变量。 这不适用于全局变量。 一个自由变量的概念最好用清单5.6.a所示的例子说明。

    代码清单5.6.a: 一个简单的嵌套函数
    def f(*args):
        x = 1
        def g():
            n = x

    对于 f 函数的代码对象,co_freevars 字段为空,而 g 函数的代码对象包含 x 值。 自由变量与 cell 变量密切相关。

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

关于字节码 co_code 的细节

如前所述,代码对象的实际虚拟机指令字节码包含在代码对象的 co_code 字段中。 例如,来自fizzbuzz 函数的字节代码是清单5.7中所示的字节字符串。

代码清单5.7: 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 模块中的 dis 函数将其转换为可阅读的格式:

代码清单5.8: 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

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

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

字节码指令的大小为两个字节,一个字节用于操作码,第二个字节用于操作码的参数。 如果操作码不带参数,则将第二个参数字节清零。 Python 虚拟机在我目前正在写这本书的机器上使用小端(Little Endian)字节编码,因此16位代码的结构如图5.0所示,其中操作码占据了较高的8位,而操作码的参数占低8位。

有时,操作码的参数可能无法放入默认的单个字节中。对于这些类型的参数,python 虚拟机使用 EXTENDED_ARG 操作码。这时, python 虚拟机的工作是接受一个太大而无法容纳一个字节的参数,并将其拆分为两个字节(我们假设此处可以容纳两个字节,但是此逻辑很容易扩展到两个字节以上)最重要的字节是 EXTENDED_ARG 操作码的参数,而最低有效字节是其实际操作码的参数。 EXTENDED_ARG 操作码将在操作码序列中的实际操作码之前出现,然后可以通过向右位移(right shift)以及与参数的其他部分进行 or 运算来重建参数。例如,如果希望将值 321 作为参数传递给 LOAD_CONST 操作码,则该值不能放入单个字节中,因此使用 EXTENDED_ARG 操作码。该值的二进制表示形式为 0b101000001,因此实际的工作操作码(LOAD_CONST)将第一个字节(1000001)作为参数(十进制65),而 EXTENDED_ARG 操作码将下一个字节(1)作为参数,因此我们有 (144, 1), (100, 65) 作为输出的指令序列。

dis 模块的文档包含了虚拟机目前实现的所有操作码的完整列表和说明。

5.2 嵌套的代码对象

另一个值得关注的代码块代码对象是正在编译的模块。 假设我们正在编译一个带有一个函数作为内容的模块,那么输出将是什么样? 为了找出答案,我们使用 python 中的 compile 函数来编译模块,如清单5.9所示。

代码清单5.9: example.py
def f():
    print(c)
    a = 1
    b = 3
    def g():
        print(a+b)
        c=2
        def h():
            print(a+b+c)

example.py 被编译后再进行反汇编,我们能够得到以下字节码:

代码清单5.10: example.py 模块代码块对应的字节码
  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 指令的函数定义。 清单5.11显示了此代码对象的内容。

代码清单5.11: example.py 模块代码对象的属性
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_argcountco_kwonlyargcount)全为0co_code 字段包含字节码指令,如清单5.10所示。 co_consts 字段是一个有趣的字段。 字段中的常量是代码对象,名称为 fNone。 其中的代码对象是 example.py 中函数 f 对应的代码对象,值 “f” 是函数的名称,“None”是函数的返回值。回想一下,python 编译器会向没有一个没有返回值的代码对象添加 return None 语句。

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

代码清单5.11: 函数 f 对应的代码对象的属性
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 的函数对象。

5.3 虚拟机中 Code Object 的实现

VM 中代码对象的实现与其在 python 中的看到的对象属性非常相似。 像大多数内置类型一样,存在一个 code 类型和一个 PyCodeObject 结构体定义了代码对象的类型和实例。 code 类型与前面各节中讨论的其他类型对象相似,因此在此不再赘述。 代码对象的实例由清单5.13中所示的结构表示。

代码清单5.13: 代码对象的 C 实现
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_stacksizeco_flagsco_cell2argco_zombieframeco_weakreflistco_extra 外,这些字段几乎都与在 python 中的代码对象中找到的字段相同。 这里,co_weakreflistco_extra 并不是真正有趣的字段, 此处的其余字段几乎和代码对象中的目的相同。 co_zombieframe 是为优化目的而存在的字段。 这保留了对以前用作上下文执行代码对象的 frame 对象的引用。 当这样的代码对象被重新执行时,它被用作执行 frame,以防止为另一个 frame 对象分配额外的内存带来的开销。

Last updated