PyCodeObject 拾遗

文摘   2024-09-23 12:16   中国台湾  

楔子


上一篇文章我们介绍了 PyCodeObject 对象,但是还遗漏了一些内容,这里再单独补充一下。



内置函数 compile


之前通过函数的 __code__ 属性获取了该函数的 PyCodeObject 对象,但是还有没有其它的方法呢?显然是有的,答案是通过内置函数 compile,不过在介绍 compile 之前,先介绍一下 eval 和 exec。

eval:传入一个字符串,然后把字符串里面的内容当做表达式

a = 1
# 所以 eval("a") 就等价于 a
print(eval("a"))  # 1
print(eval("1 + 1 + 1"))  # 3

注意:eval 是有返回值的,返回值就是字符串里面的内容。所以 eval 接收的字符串里面一定是一个表达式,表达式计算之后是一个具体的值,比如 a = eval("1 + 2"),等价于 a = 3

但如果是语句的话,比如 a = eval("b = 3"),这样等价于 a = (b = 3),显然这会出现语法错误。因此 eval 函数把字符串两边的引号剥掉之后,得到的一定是一个普通的值。

try:
    print(eval("xxx"))
except NameError as e:
    print(e)  # name 'xxx' is not defined

此时等价于 print(xxx),但是 xxx 没有定义,所以报错。

# 此时是合法的,等价于 print('xxx')
print(eval("'xxx'"))  # xxx

以上就是 eval 函数,使用起来还是很方便的。

exec:传入一个字符串,把字符串里面的内容当成语句来执行,这个是没有返回值的,或者说返回值是 None。

# 相当于 a = 1
exec("a = 1")  
print(a)  # 1

statement = """
a = 123
if a == 123:
    print("a 等于 123")
else:
    print("a 不等于 123")
"""

exec(statement)  # a 等于 123

注意:a 等于 123 并不是 exec 返回的,而是把上面那坨字符串当成普通代码执行的时候 print 出来的。这便是 exec 的作用,将字符串当成语句来执行。

所以使用 exec 可以非常方便地创建多个变量。

import random

for i in range(15):
    exec(f"a{i} = {random.randint(1100)}")

print(a1)  # 72
print(a2)  # 21
print(a3)  # 38
print(a4)  # 32

那么 exec 和 eval 的区别就显而易见了,eval 是要求字符串里面的内容能够当成一个值,并且该值就是 eval 函数的返回值。而 exec 则是直接执行里面的内容,返回值是 None。

print(eval("1 + 1"))  # 2
print(exec("1 + 1"))  # None

# 相当于 a = 2
exec("a = 1 + 1")
print(a)  # 2

try:
    # 相当于 a = 2,但很明显 a = 2 是一个语句
    # 它无法作为一个值,因此放到 eval 里面就报错了
    eval("a = 1 + 1")
except SyntaxError as e:
    print(e)  # invalid syntax (<string>, line 1)

还是很好区分的,但是 eval 和 exec 在生产中尽量要少用。另外,eval 和 exec 还可以接收第二个参数和第三个参数,我们在介绍名字空间的时候再说。

compile:关键来了,它执行后返回的就是一个 PyCodeObject 对象。

这个函数接收哪些参数呢?

  • 参数一:当成代码执行的字符串

  • 参数二:可以为这些代码起一个文件名

  • 参数三:执行方式,支持三种,分别是 exec、single、eval


我们演示一下。

# exec:将源代码当做一个模块来编译
# single:用于编译一个单独的 Python 语句(交互式)
# eval:用于编译一个 eval 表达式
statement = "a, b = 1, 2"
# 这里我们选择 exec,当成一个模块来编译
co = compile(statement, "古明地觉的编程教室""exec")

print(co.co_firstlineno)  # 1
print(co.co_filename)  # 古明地觉的编程教室
print(co.co_argcount)  # 0
# 我们是以 a, b = 1, 2 这种方式赋值
# 所以 (1, 2) 会被当成一个元组加载进来
# 从这里我们看到,元组是在编译阶段就已经确定好了
print(co.co_consts)  # ((1, 2), None)

statement = """
a = 1
b = 2
"""

co = compile(statement, "<file>""exec")
print(co.co_consts)  # (1, 2, None)
print(co.co_names)  # ('a', 'b')

我们后面在分析 PyCodeObject 的时候,会经常使用 compile 函数。

然后 compile 还可以接收一个 flags 参数,也就是第四个参数,它的默认值为 0,表示按照标准模式进行编译,就是之前说的那几步。

  • 对文本形式的源代码进行分词,将其切分成一个个的 Token;

  • 对 Token 进行语法解析,生成抽象语法树(AST);

  • 将 AST 编译成 PyCodeObject 对象,简称 code 对象或者代码对象;


但如果将 flags 指定为 1024,那么 compile 函数在生成 AST 之后会直接停止,然后返回一个 ast.Module 对象。

print(
    compile("a = 1""<file>""exec").__class__
)  # <class 'code'>

print(
    compile("a = 1""<file>""exec", flags=1024).__class__
)  # <class 'ast.Module'>

ast 模块是和 Python 的抽象语法树相关的,那么问题来了,这个 ast.Module 对象能够干什么呢?别着急,我们后续在介绍栈帧的时候说。不过由于抽象语法树比较底层,因此知道 compile 的前三个参数的用法即可。



字节码与反编译


关于 Python 的字节码,是后面剖析虚拟机的重点,现在先来看一下。我们知道执行源代码之前会先编译得到 PyCodeObject 对象,里面的 co_code 字段指向了字节码序列,或者说字节码指令集。

虚拟机会根据这些指令集来进行一系列的操作(当然也依赖其它的静态信息),从而完成对程序的执行。关于指令,解释器定义了 200 多种,我们大致看一下。

// Include/opcode.h
#define CACHE                                    0
#define POP_TOP                                  1
#define PUSH_NULL                                2
#define INTERPRETER_EXIT                         3
#define END_FOR                                  4
#define END_SEND                                 5
#define NOP                                      9
#define UNARY_NEGATIVE                          11
#define UNARY_NOT                               12
#define UNARY_INVERT                            15
#define RESERVED                                17
#define BINARY_SUBSCR                           25
#define BINARY_SLICE                            26
#define STORE_SLICE                             27
#define GET_LEN                                 30
#define MATCH_MAPPING                           31
#define MATCH_SEQUENCE                          32
#define MATCH_KEYS                              33
#define PUSH_EXC_INFO                           35
#define CHECK_EXC_MATCH                         36
#define CHECK_EG_MATCH                          37
#define WITH_EXCEPT_START                       49
#define GET_AITER                               50
#define GET_ANEXT                               51
#define BEFORE_ASYNC_WITH                       52
#define BEFORE_WITH                             53
#define END_ASYNC_FOR                           54
#define CLEANUP_THROW                           55
#define STORE_SUBSCR                            60
#define DELETE_SUBSCR                           61
#define GET_ITER                                68
#define GET_YIELD_FROM_ITER                     69
#define LOAD_BUILD_CLASS                        71
#define LOAD_ASSERTION_ERROR                    74
#define RETURN_GENERATOR                        75
#define RETURN_VALUE                            83
// ...
// ...

所谓字节码指令其实就是个整数,多个指令组合在一起便是字节码指令集(字节码序列),它是一个 bytes 对象。当然啦,指令集里面不全是指令,索引(偏移量)为偶数的字节表示指令,索引为奇数的字节表示指令参数,后续会细说。

然后我们可以通过反编译的方式查看每行 Python 代码都对应哪些操作指令。

# Python 的 dis 模块专门负责干这件事情
import dis

def foo(a, b):
    c = a + b
    return c

# 里面接收 PyCodeObject 对象
# 当然函数也是可以的,会自动获取 co_code
dis.dis(foo)
"""
  1           0 RESUME                   0

  2           2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP                0 (+)
             10 STORE_FAST               2 (c)

  3          12 LOAD_FAST                2 (c)
             14 RETURN_VALUE
"""

字节码反编译后的结果多么像汇编语言,其中第一列是源代码行号,第二列是字节码偏移量,第三列是字节码指令(也叫操作码),第四列是指令参数(也叫操作数)。Python 的字节码指令都是成对出现的,每个指令会带有一个指令参数。

查看字节码也可以使用 opcode 模块:

from opcode import opmap

opmap = {v: k for k, v in opmap.items()}

def foo(a, b):
    c = a + b
    return c

code = foo.__code__.co_code
for i in range(0, len(code), 2):
    print("操作码: {:<12} 操作数: {}".format(
        opmap[code[i]], code[i+1]
    ))
"""
操作码: RESUME       操作数: 0
操作码: LOAD_FAST    操作数: 0
操作码: LOAD_FAST    操作数: 1
操作码: BINARY_OP    操作数: 0
操作码: CACHE        操作数: 0
操作码: STORE_FAST   操作数: 2
操作码: LOAD_FAST    操作数: 2
操作码: RETURN_VALUE 操作数: 0
"""

总之字节码就是一段字节序列,转成列表之后就是一堆数字。偶数位置表示指令本身,而每个指令后面都会跟一个指令参数,也就是奇数位置表示指令参数。

所以指令本质上只是一个整数:

虚拟机会根据不同的指令执行不同的逻辑,说白了 Python 虚拟机执行字节码的逻辑就是把自己想象成一颗 CPU,并内置了一个巨型的 switch case 语句,其中每个指令都对应一个 case 分支。

然后遍历整条字节码,拿到每一个指令和指令参数。接着对指令进行判断,不同的指令进入不同的 case 分支,执行不同的处理逻辑,直到字节码全部执行完毕或者程序出错。

关于执行字节码的具体流程,等介绍栈帧的时候细说。

古明地觉的编程教室
Python、Rust 程序猿,你感兴趣的内容我都会写,点个关注吧(#^.^#)
 最新文章