楔子
上一篇文章我们介绍了 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 分支,执行不同的处理逻辑,直到字节码全部执行完毕或者程序出错。
关于执行字节码的具体流程,等介绍栈帧的时候细说。