楔子
上一篇文章我们介绍了 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(1, 5):
exec(f"a{i} = {random.randint(1, 100)}")
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 分支,执行不同的处理逻辑,直到字节码全部执行完毕或者程序出错。
关于执行字节码的具体流程,等介绍栈帧的时候细说。