05 分支语句:控制流让程序具备基本的运算能力
你好,我是海纳。
上一节课,我们让虚拟机成功地加载并执行了标准Python3.8版本的字节码,我知道你已经迫不及待想继续实现对象系统,以支持整数和字符串等更多的数据类型了。但只实现完备的对象系统并不能让虚拟机看上去功能更加强大,而控制流却可以让我们做更多的测试,例如,支持了分支和循环语句以后,就可以实现一个计算 fibonacci 数列的程序。所以我们决定实现了控制流之后,再重新思考对象系统。
典型的两种控制流结构是分支选择和循环结构,这节课我们先研究分支语句是如何实现的。
分支语句
为了研究 Python 字节码是如何表达分支结构的,我们先创建一个包含分支语句的测试文件。
使用以下命令,把上述文件编译成 pyc 文件。
然后通过show_file.py 查看这个文件结构,得到如下结果:
code
argcount 0
nlocals 0
stacksize 2
flags 0040
code
640064016b04721265006400830101006e08650064018301010065006402
8301010064035300
1 0 LOAD_CONST 0 (2)
2 LOAD_CONST 1 (1)
4 COMPARE_OP 4 (>)
6 POP_JUMP_IF_FALSE 18
2 8 LOAD_NAME 0 (print)
10 LOAD_CONST 0 (2)
12 CALL_FUNCTION 1
14 POP_TOP
16 JUMP_FORWARD 8 (to 26)
4 >> 18 LOAD_NAME 0 (print)
20 LOAD_CONST 1 (1)
22 CALL_FUNCTION 1
24 POP_TOP
6 >> 26 LOAD_NAME 0 (print)
28 LOAD_CONST 2 (3)
30 CALL_FUNCTION 1
32 POP_TOP
34 LOAD_CONST 3 (None)
36 RETURN_VALUE
consts
2
1
3
None
names ('print',)
varnames ()
freevars ()
cellvars ()
filename 'test_if.py'
我们看到代码中所使用的三个整数都已经放在了 consts 表中。反编译出来的字节码部分则出现了三个尚未支持的字节码,分别是 COMPARE_OP、POP_JUMP_IF_FALSE 和JUMP_FORWARD。从 Python 虚拟机的标准文档或者 CPython 的源代码里都可以查到这三个字节码的编号,在 bytecode.hpp 里添加它们。
/* Comparison operator */
static const unsigned char COMPARE_OP = 107;
/* Number of bytes to skip */
static const unsigned char JUMP_FORWARD = 110;
static const unsigned char POP_JUMP_IF_FALSE = 114;
接下来,我们重点看看这三个字节码分别是如何实现的。
实现比较指令
第一个要处理的字节码是 COMPARE_OP,第 3 节课我们曾经提到字节码操作数编号大于 90 的都是带参数的。COMPARE_OP 的编号是 107,所以它是带参数的。在这节课的例子里,它的参数是 4(反编译结果的第 11 行)。这个参数所代表的是比较操作符的类型,比如 4 就代表大于,0 代表小于,2 代表等于。你可以看一下完整的比较操作所对应的类型。
接下来就可以在 Interpreter 里添加 COMPARE_OP 的实现了。在此之前,我需要引入一个小的代码重构。为了使代码更简洁,我在代码里定义了宏 PUSH 和 POP,这样可以避免大量的手动输入。
最后,我们就可以在 Interpreter 中实现我们这节课提到的三个字节码了。
case ByteCode::COMPARE_OP:
w = POP();
v = POP();
switch(op_arg) {
case ByteCode::GREATER:
PUSH(v->greater(w));
break;
case ByteCode::LESS:
PUSH(v->less(w));
break;
case ByteCode::EQUAL:
PUSH(v->equal(w));
break;
case ByteCode::NOT_EQUAL:
PUSH(v->not_equal(w));
break;
case ByteCode::GREATER_EQUAL:
PUSH(v->ge(w));
break;
case ByteCode::LESS_EQUAL:
PUSH(v->le(w));
break;
default:
printf("Error: Unrecognized compare op %d\n", op_arg);
}
break;
在这段代码中,我们为每一种比较操作都提供了一个方法,又因为各个对象的比较方式各不相同,所以每个对象都会有自己的实现。要完成这个功能,这些比较方法必须和print方法一样,是虚函数,也就是必须要使用 virtual 关键字进行修饰。
class HiObject {
public:
virtual void print() {}
virtual HiObject* add(HiObject* x){}
virtual HiObject* greater (HiObject* x) {};
virtual HiObject* less (HiObject* x) {};
virtual HiObject* equal (HiObject* x) {};
virtual HiObject* not_equal(HiObject* x) {};
virtual HiObject* ge (HiObject* x) {};
virtual HiObject* le (HiObject* x) {};
};
在 HiInteger 类中,我们必须提供用于整数比较的方法,也就是在子类中覆写父类的方法。在这里,我只列出大于操作所对应的greater方法,而等于、不等于、小于等操作所对应的方法,你可以自己补充。
HiObject* HiInteger::greater(HiObject* x) {
if (_value > ((HiInteger*)x)->_value)
return new HiInteger(1);
else
return new HiInteger(0);
}
注意这个方法的返回值,在 Python 中 True 和 False 都是对象,所以在这里,我们定义用于比较操作的几个方法的时候,它的返回值也是 HiObject 类型的指针。但现在我们的虚拟机对象类型还不完整,没有真正的 True 和 False 对象,就临时使用 0 代表 False,使用 1 代表 True。这样的替代虽然并不完全符合 Python 语义,但就目前而言已经足够了。
实现跳转功能
在刚开始实现执行器的时候,我就解释过 pc 变量的含义,它是一个程序计数器,代表虚拟机当前执行到哪条指令了。当控制流因为分支选择而发生跳转的时候,本质上就是改变这个程序计数器,让它不再顺序向下取指,而是跳转到另外一个目标地址,去把那里的指令取出来执行。所以,所有的跳转指令本质上就是对程序计数器的干预,使它指向我们期望的地址。
然后再考察 POP_JUMP_IF_FALSE 的参数,在这节课的例子里,它是 18。这个参数代表的是绝对地址,也就是字节码前面的编号。你看一下地址编号为 18 的那条指令,反编译工具已经很贴心地帮我们标记了 “>>” 符号,地址为 18 的那条 LOAD_NAME 指令就是我们要跳转的目标地址。经过分析,POP_JUMP_IF_FALSE 的实现就非常简单了。
上述实现只做了一个动作:当栈顶值为 0 时,把 pc 的值修改成目标地址(第4行)。刚刚我们讲到,现阶段我们用值为 0 的整数代表 False,所以当栈顶值为 False 的时候,就跳转到参数所指定的目标地址,如果是 True,就什么也不做,继续执行下面的语句。
三个字节码,我们已经实现了两个,还剩下最后一个:JUMP_FORWARD。这个字节码其实是最简单的,它也是带参数的,它的参数是一个无符号正数,代表相对地址,也就是用当前的 pc 加上这个参数才是要跳转的目标地址。就像它的名称所指示的,这个字节码只能向前跳转,所以它的参数一定是一个正数。你可以看一下它的代码。
到这里,我们就把分支结构所需要的字节码全都实现了。现在可以编译执行一下。
src/build# make all
...
src/build# ./vm/pvm test_if.pyc
magic number is 0xa0df303
moddate is 0x5b66d766
flags is 0x40
parse OK!
2
3
我们看到,结果是打印出了 2 和 3,这个结果符合预期。你不妨尝试一下等于、小于、不等于等其他比较操作,看看结果是否正确。
实现True、False和None
在前面的实现中,我们要使用 True 和 False 时,都是使用整型的 1 和 0 代替的。而且每次使用的时候,都要重新创建一个整型对象。实际上,整个虚拟机中只需要一个唯一的 True 对象和唯一的 False 对象。要实现全局唯一,我们自然就会想到 static 变量。除了 True 和 False,还有一个是 None 对象,它也是全局唯一的。
类似 True 和 False 这种全局唯一变量,未来还会遇到很多,所以我们把所有的这些变量都集中起来放到一个类中。我们不妨把这个类叫作 Universe,你可以看一下它的定义。
// runtime/universe.hpp
class Universe {
public:
static HiInteger* HiTrue;
static HiInteger* HiFalse;
static HiObject* HiNone;
public:
static void genesis();
static void destroy();
};
在 Universe 类中定义了三个静态变量,分别代表 True、False 和 None,又定义了两个方法,一个名为Genesis,意思是创世纪,就像宇宙的诞生,在这个函数中创建虚拟机最原始的对象结构。虚拟机里的所有对象以后都会以这个方法为起点。另一个是 destroy,顾名思义,这个方法是在虚拟机退出的时候调用,用来销毁对象,释放资源,清理空间。下面我们看一下这个类的具体实现。
// runtime/universe.cpp
HiInteger* Universe::HiTrue = NULL;
HiInteger* Universe::HiFalse = NULL;
HiObject* Universe::HiNone = NULL;
void Universe::genesis() {
HiTrue = new HiInteger(1);
HiFalse = new HiInteger(0);
HiNone = new HiObject();
}
void Universe::destroy() {
}
首先,是 True、False 和 None 对象的初始化。static 变量在源文件中定义初始化,而不是在头文件中,是为了避免多个目标文件的链接冲突。我们在 genesis 方法里做真正的初始化,这样做的好处是,我们对于对象初始化的时机有绝对的掌控权,而不是交给编译器去决定。也就是说,只有我们明确地调用 genesis 的时候,对象才会真正开始初始化,这样我们就有机会在对象初始化之前做一些额外的工作。
实际上,这里完全可以把 HiTrue 和 HiFalse 这两个变量初始化为任意对象。对于虚拟机来说,真正有意义的是它们的地址,而不是它们的值。也就是说,这里只要指定一个绝对地址,然后说它就是 True,或者说它就是 None 就可以。
经过这样的改进,BinaryFileParser 在解析常量表的时候,就可以把字母“N”解析成 HiNone,而不是 NULL 指针。以此类推,HiInteger 的 greater 方法,也不用每次执行都创建一个新的对象,只要返回一个全局唯一的 True 和 False就可以了。
// use HiTrue and HiFalse instead of creating a new
// object everytime.
HiObject* HiInteger::greater(HiObject* x) {
if (_value > ((HiInteger*)x)->_value)
//return new HiInteger(1);
return Universe::HiTrue;
else
//return new HiInteger(0);
return Universe::HiFalse;
}
其他的方法,比如 less、equal 等都有相同的改动。
还有一处修改是要注意的,interpreter 中对 JUMP_IF_FALSE 的实现,在判断 False 时,我们也不用再使用强制类型转换了,而是换成更加简单的地址比较。
case ByteCode::POP_JUMP_IF_FALSE:
v = POP();
//if (((HiInteger*)v)->value() == 0)
if (v == Universe::HiFalse)
pc = op_arg;
break;
到这里,分支操作所需要的比较和跳转功能就全部实现了。
总结
控制流的作用是控制程序的执行顺序,主要包含分支和循环两种结构,这节课我们重点介绍了分支语句的实现原理。
比较指令和跳转指令是实现分支语句的基础。Python 字节码中用于比较判断的指令是 COMPARE_OP,这节课的第二部分我们实现了这个字节码。每个比较指令结果都是布尔值 True 或者 False。在第二部分,我们临时使用了整型变量 1 和 0 来代替。后面我们又使用全局变量补全了 True、False 和 None 的实现。
用于跳转的指令,这节课中出现两条,第一条是POP_JUMP_IF_FALSE,它的含义是取出栈顶元素并判断,如果栈顶元素是 True,就不发生跳转,如果栈顶元素是 False,就跳转到这条字节码的参数所指定的目标地址,所以这是一条绝对跳转指令。
这节课出现的第二条跳转指令是 JUMP_FORWARD。执行这条指令不需要进行条件判断,但是它的参数却有些不一样。这条字节码的参数是目标地址相对于当前指令地址的偏移量,计算目标地址的时候,需要用当前地址加上这个偏移量。
实现了这些功能,我们这节课开头的例子就能正确执行了。成功地实现分支语句后,下节课我们继续实现循环语句。
注:点击链接查看课程代码地址
思考题
None 也可以用于条件判断,Python语言是如何处理它的呢?
如果执行我给出的这段代码,它的结果会是什么?我们的虚拟机又该如何修改,来支持这个特性呢?
欢迎你把你的答案分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!
- 细雨平湖 👍(1) 💬(1)
讲得很不错。
2024-05-15 - 细露仔 👍(0) 💬(1)
哎,这个show_file.py是什么时候冒出来的啊?我全局搜索也没发现前面的哪个章节有实现过他啊?
2024-06-08 - qinsi 👍(0) 💬(3)
Python 3.10编译出的跳转指令参数有差别 7212 6 POP_JUMP_IF_FALSE 18 7209 6 POP_JUMP_IF_FALSE 9 (to 18) 6e08 16 JUMP_FORWARD 8 (to 26) 6e04 16 JUMP_FORWARD 4 (to 26)
2024-05-19 - ifelse 👍(0) 💬(0)
学习打卡
2024-10-20