Skip to content

06-循环语句:控制流让虚拟机执行复杂的运算

你好,我是海纳。

上一节课我们介绍了控制流中的第一种基本结构——分支语句。这一节课我们会介绍第二种基本结构——循环语句。

Python 中有两种循环结构,分别是 while 语句和 for 语句。Python 中的 while 语句和其他语言中的 while 语句差不多,但是 for 语句却和 C、C++、Java 等常见语言有很大的区别,Python 中的 for 语句本质上是一个迭代器。我们要等到实现了类机制以后,才能实现迭代器,所以这节课我们的重点是实现 while 循环。

实现变量

要实现循环结构,首先就要实现变量。在每次循环中,变量都应该有所变化,这样才能在若干次循环以后,破坏循环继续的条件,从而跳出这个循环。接下来我们就实现这个变量机制。我们还是从一个最简单的例子开始。

python
a = 1
b = a + 1
print(a)
print(b)

我们按照以前的方法把它编译成 pyc 文件,然后通过 show_file 工具查看它的字节码。

python
  1           0 LOAD_CONST               0 (1)
              2 STORE_NAME               0 (a)

  2           4 LOAD_NAME                0 (a)
              6 LOAD_CONST               0 (1)
              8 BINARY_ADD
             10 STORE_NAME               1 (b)

  3          12 LOAD_NAME                2 (print)
             14 LOAD_NAME                0 (a)
             16 CALL_FUNCTION            1
             18 POP_TOP

  4          20 LOAD_NAME                2 (print)
             22 LOAD_NAME                1 (b)
             24 CALL_FUNCTION            1
             26 POP_TOP
             28 LOAD_CONST               1 (None)
             30 RETURN_VALUE
   consts
      1
      None
   names ('a', 'b', 'print')
   varnames ()
   freevars ()
   cellvars ()

在这个反编译的结果中出现了一个新的字节码:STORE_NAME(第2行)。同时,我们注意到,names 表里除了 print 之外又出现了两个字符串,它们正好就是测试代码里使用的那两个变量:a 和 b。

在介绍 pyc 文件结构的时候,我们已经说过了,names 表里放的是 CodeObject 所使用的变量的名字,这里正好通过实例验证了这一说法。

就像名字里暗示的那样,STORE_NAME 和 LOAD_NAME 跟 names 表有关系。在第一节课的图里,除了操作数栈之外,还有一个变量表。变量表的作用是记录在程序执行过程中,变量的实际值是多少。

要实现这两个字节码,我们就必须要先实现变量表。

图片

变量表是一个典型的键值(key-value)二元结构,它是一张表,每个键(key)都对应着一个值(value),键在这张表里是唯一的,不能重复。

这种结构,我们通常称之为 map。map 的实现有很多种,最常见的有基于哈希表的 HashMap,还有基于二叉排序树的 BinaryMap 等。由于 HashMap 要定义键的哈希函数,而有序的 map 又需要键之间的偏序关系,所以我们暂时都先不采用。

这里我们使用一种最简单的方式:直接使用数组实现。当插入一个键值对的时候,如果 map 里不包含 key,就直接插入。如果已经包含了 key,就把它原来的值更新成新的值。查询的时候,也是遍历整个数组,找到与查询的键相等的那个键,返回对应的值。

这里我给出一个简单的实现。

c++
template <typename K, typename V>
class MapEntry {
public:
    K _k;
    V _v;
    
    MapEntry(const MapEntry<K, V>& entry);
    MapEntry(K k, V v) : _k(k), _v(v) {}
    MapEntry() : _k(0), _v(0) {}
};

template <typename K, typename V>
class Map {
private:
    MapEntry<K, V>* _entries;
    int _size;
    int _length;
    
    void expand();
public:
    Map();

    int  size() { return _size; }
    void put(K k, V v);
    V    get(K k);
    K    get_key(int index);
    bool has_key(K k);
    V    remove(K k);
    int  index(K k);
    MapEntry<K, V>* entries() { return _entries; }
};

Map 的定义与 ArrayList 十分相似,内存都是可以按需增长的。其中,put 方法可以把键值对存入 Map 结构中。index 方法可以返回参数 k 在 _entries 数组中的序号。其他的方法,比如get、remove,都和它们名字所指示的意义一样,用于获取值和删除键值对。

接下来就可以实现 Map 里定义的所有函数了。我建议你不要照着课程中的代码抄,最好是自己实现一遍 Map 这种结构。这是一个很好的练手机会。为了提高编程水平,不断地练习是必不可少的。

c++
template <typename K, typename V>
Map<K, V>::Map() {
    _entries = new MapEntry<K, V>[8];
    _length  = 8;
    _size    = 0;
}

template <typename K, typename V>
MapEntry<K, V>::MapEntry(const MapEntry<K, V>& entry) {
    _k = entry._k;
    _v = entry._v;
}

template <typename K, typename V>
void Map<K, V>::put(K k, V v) {
    for (int i = 0; i < _size; i++) {
        if (_entries[i]._k->equal(k) == Universe::HiTrue) {
            _entries[i]._v = v;
            return;
        }
    }
    
    expand();
    _entries[_size++] = MapEntry<K, V>(k, v);
}

template <typename K, typename V>
bool Map<K, V>::has_key(K k) {
    int i = index(k);
    return i >= 0;
}

template <typename K, typename V>
V Map<K, V>::get(K k) {
    int i = index(k);
    if (i < 0)
        return Universe::HiNone;
    else
        return _entries[i]._v;
}

template <typename K, typename V>
int Map<K, V>::index(K k) {
    for (int i = 0; i < _size; i++) {
        if (_entries[i]._k->equal(k) == Universe::HiTrue) {
            return i;
        }
    }

    return -1;
}

template <typename K, typename V>
void Map<K, V>::expand() {
    if (_size >= _length) {
        MapEntry<K, V>* new_entries = new MapEntry<K, V>[_length << 1];
        for (int i = 0; i < _size; i++) {
            new_entries[i] = _entries[i];
        }
        _length <<= 1;
        delete[] _entries;
        _entries = new_entries;
    }
}

template <typename K, typename V>
V Map<K, V>::remove(K k) {
    int i = index(k);

    if (i < 0)
        return 0; 

    V v = _entries[i]._v;
    _entries[i] = _entries[--_size];
    return v;
}

template <typename K, typename V>
K Map<K, V>::get_key(int index) {
    return _entries[index]._k;
}

template class Map<HiObject*, HiObject*>;

这段代码虽然行数很多,但是逻辑都很简单。有两个地方是你需要注意的。第一处是 put 方法的实现(第15行)。由于 Map 中的 key 是唯一的,不会重复,所以在往 Map 插入键值对的时候,需要先检查 Map 中是否已经存在相同的 key 了。如果存在,就直接更新它的值,如果不存在,再插入键值对。

第二处要注意的是 remove 方法的实现。由于 Map 中的元素没有先后顺序的要求,所以我们可以将键值对任意排列。当要删除容器中的某一个元素的时候,我们只需要将最后一个元素与待删除元素交换位置,这样待删除元素就出现在最后一位了。这个时候只需要把最后一个元素删除就可以了。而我们知道,删除最后一个元素是非常高效的,把 size 变量减 1 就可以实现了。

有了 map 结构,我们就可以进一步实现变量表了。解释器中的变量表是一个以字符串为键的 map 结构,变量名存储在 CodeObject 的 names 列表中。所以,我们就可以这样实现变量表:

plain
void Interpreter::run(CodeObject* codes) {
    // ...
    HiList* names  = codes->_names;
    Map<HiObject*, HiObject*>* locals  = new Map<HiObject*, HiObject*>();

    while (pc < code_length) {
        unsigned char op_code = codes->_bytecodes->value()[pc++];
        short op_arg = (codes->_bytecodes->value()[pc++] & 0xFF);
        
        // ....

        switch (op_code) {
            case ByteCode::LOAD_NAME:
                v = names->get(op_arg);
                w = locals->get(v);
                PUSH(w);
                break;

            case ByteCode::STORE_NAME:
                v = names->get(op_arg);
                locals->put(v, POP());
                break;
             //....
         }
     }
}

在变量表的基础上,我们进一步支持了 LOAD_NAME 和 STORE_NAME 两个字节码。

STORE_NAME 的作用是在局部变量表 locals 写入变量的值。变量的名称是一个字符串,存储在 names 中,所以要先从 names 中取出变量名(第 20 行),然后以变量名为键,将值写入变量表(第21行)。

LOAD_NAME 的过程与 STORE_NAME 相反,它负责从变量表里取出值。

由于循环结构的实现依赖于循环变量的迭代修改,所以在实现了变量的功能以后,我们才可以进一步支持循环结构。下面我们就开始实现循环结构。

循环内的跳转

在循环内有三种跳转,第一种是循环体正常执行,正常结束;第二种是使用 continue 语句,跳转回循环头部再进行条件判断;第三种是使用 break 语句,跳转到循环结束处。每一次循环体执行结束以后,都要跳到循环开始的地方再进行条件判断,来决定是否进入下一次循环。举一个最简单的例子,为了方便研究,我们把源代码和字节码混合在一起打印。

c++
i = 0
  1           0 LOAD_CONST               0 (0)
              2 STORE_NAME               0 (i)

while i < 2:
  2           4 LOAD_NAME                0 (i)
              6 LOAD_CONST               1 (2)
              8 COMPARE_OP               0 (<)
             10 POP_JUMP_IF_FALSE       20 (to 40)

    print(i)
  3     >>   12 LOAD_NAME                1 (print)
             14 LOAD_NAME                0 (i)
             16 CALL_FUNCTION            1
             18 POP_TOP

    i = i + 1
  4          20 LOAD_NAME                0 (i)
             22 LOAD_CONST               2 (1)
             24 BINARY_ADD
             26 STORE_NAME               0 (i)

  2          28 LOAD_NAME                0 (i)
             30 LOAD_CONST               1 (2)
             32 COMPARE_OP               0 (<)
             34 POP_JUMP_IF_TRUE         6 (to 12)
             36 LOAD_CONST               3 (None)
             38 RETURN_VALUE
        >>   40 LOAD_CONST               3 (None)
             42 RETURN_VALUE

从这个反编译结果中,我们可以看到,这里面只有一个新增的字节码,那就是 POP_JUMP_IF_TRUE。除了它之外,其他的字节码我们都已经很熟悉了。

注:不同版本的编译器得到的字节码可能会有很大的不同,例如Python 2.7会使用JUMP_ABSOLUTE来实现绝对跳转。

如果循环判断不成功,就使用 POP_JUMP_IF_FALSE,跳过整个循环体。这个机制与 if 语句完全相同,这里不再赘述。

好了,到此为止,我们就把 while 循环所需要的字节码全部实现了。你可以编译运行一下,上面举的例子虽然简单,但都是可以运行的。比如下面的代码作用是打印 Fibonacci 数列的前10项,编译之后,在我们的虚拟机上就可以正确地打印出结果。

c++
a = 1 
b = 0 
i = 0 

print(a)
print(b)
print(i)

while i < 10: 
    print(a)
    t = a 
    a = a + b 
    b = t 

    i = i + 1

实现continue语句

影响 while 控制流的两个重要的关键字,分别是 continue 和 break。continue 的作用是直接跳到循环开始的地方进行条件判断,来决定是否要执行下一次循环。这种跳转将 continue 后面的循环体部分直接跳过了。

在查看 continue 的具体实现之前,我们可以猜想一下,这种跳转并不需要额外的数据结构,那是不是只要一个绝对跳转语句就可以实现呢?我们写一个例子验证一下。

python
i = 0

while i < 10:

  3     >>    4 LOAD_NAME                0 (i)
              6 LOAD_CONST               1 (10)
              8 COMPARE_OP               0 (<)
             10 POP_JUMP_IF_FALSE       25 (to 50)

    if i < 2:

  4     >>   12 LOAD_NAME                0 (i)
             14 LOAD_CONST               2 (2)
             16 COMPARE_OP               0 (<)
             18 POP_JUMP_IF_FALSE       11 (to 22)

        continue
  5          20 JUMP_ABSOLUTE            2 (to 4)

    print(i)

  7     >>   22 LOAD_NAME                1 (print)
             24 LOAD_NAME                0 (i)
             26 CALL_FUNCTION            1
             28 POP_TOP

    i = i + 1

我们只展示了最关键的部分。注意位置为 20 的那条 JUMP_ABSOLUTE 指令(第 18 行),它就是continue 语句翻译出来的结果。Python 通过绝对跳转来实现 continue 语义。

JUMP_ABSOLUTE 这个字节码的作用是当循环体执行结束以后,跳到循环开始的位置,进行条件判断来决定是否进入下一次循环。它的参数是一个绝对地址,例如在刚刚那个例子里,参数是 9,就代表下一个要执行的字节码位置是9。我们看一下位置为 9 的那条字节码,恰好就是 i < 2 这个比较开始的地方。所以这条字节码的实现如下:

c++
// code/bytecode.hpp
class ByteCode {
    ......
    static const unsigned char JUMP_ABSOLUTE = 113;
    ......
};

// runtime/interpreter.cpp
void Interpreter::run(CodeObject* codes) {
    ....
    while (pc < code_length) {
	    switch (op_code) {
            case ByteCode::JUMP_ABSOLUTE:
                pc = op_arg;
                break;
            ....
        }
    }
}

如果循环判断不成功,就使用 POP_JUMP_IF_FALSE 跳过整个循环体,这个机制与 if 语句完全相同,这里不再赘述。

到此为止,这节课的 continue 的例子就可以正常执行了。接下来我们实现第三种跳转——break语句。

我们先用一个简单的例子来测试一下。

c++
i = 0

while i < 5:
    print(i)
    if i == 3:
        break
    i = i + 1

print(i)

得到的字节码如下所示:

plain
  1           0 LOAD_CONST               0 (0)
              2 STORE_NAME               0 (i)

  3           4 LOAD_NAME                0 (i)
              6 LOAD_CONST               1 (5)
              8 COMPARE_OP               0 (<)
             10 POP_JUMP_IF_FALSE       23 (to 46)

  4     >>   12 LOAD_NAME                1 (print)
             14 LOAD_NAME                0 (i)
             16 CALL_FUNCTION            1
             18 POP_TOP

  5          20 LOAD_NAME                0 (i)
             22 LOAD_CONST               2 (3)
             24 COMPARE_OP               2 (==)
             26 POP_JUMP_IF_FALSE       15 (to 30)

  6          28 JUMP_FORWARD             8 (to 46)

  7     >>   30 LOAD_NAME                0 (i)
             32 LOAD_CONST               3 (1)
             34 BINARY_ADD
             36 STORE_NAME               0 (i)

  3          38 LOAD_NAME                0 (i)
             40 LOAD_CONST               1 (5)
             42 COMPARE_OP               0 (<)
             44 POP_JUMP_IF_TRUE         6 (to 12)

  9     >>   46 LOAD_NAME                1 (print)
             48 LOAD_NAME                0 (i)
             50 CALL_FUNCTION            1
             52 POP_TOP
             54 LOAD_CONST               4 (None)
             56 RETURN_VALUE

第 17 行的 POP_JUMP_IF_FALSE 代表了 (i==3) 这个条件不满足的时候,应该继续执行循环体。第 19 行的 JUMP_FORWARD 则代表 (i == 3) 条件满足时,应该直接跳到循环体结尾处。

到这里,分支语句和循环语句就全部实现完了。你可以在自己的虚拟机上实验下面这个循环嵌套代码,看看是否能正确执行。

python
i = 0
j = 0
while j < 3:
    i = 0

    while i < 10:
        if i == 3:
            break
        i = i + 1

    j = j + 1

总结

这节课我们实现了循环语句。变量的读写操作和跳转指令支撑了循环语句的实现。

第一部分我们实现了变量的读写操作。在解释器里,除了操作数栈之外,最重要的数据结构就是变量表了。变量表采用关联数据结构,也就是 Map 来实现。LOAD 和 STORE 操作就是针对变量表的读写。

第二部分我们重点实现了 POP_JUMP_IF_TRUE 指令。它的实现是比较简单的,这里需要你重点理解 while 语句是如何对应到这条跳转指令的。

第三部分我们分析了 continue 语句和 break 语句,并重点实现了 JUMP_ABSOLUTE 指令。这是一条绝对跳转指令。通过以上几种跳转指令,循环语句就全部能运行了,包括比较复杂的嵌套循环语句也能正常执行。

相比于2.7版本的字节码,3.8版本的循环语句极大地简化了。如果你有余力的话,也可以研究一下Python 2.7的字节码是如何实现的。

到此为止,虚拟机已经具备了一定的计算能力。在过去的两节课中,我们遇到过很多次需要处理对象的情况。当时我们都是通过一些手段把问题回避过去了,那么下节课我们就应该把对象体系重新梳理一下,让它能够支持整数、浮点数、字符串等更多的基本数据类型。

思考题

老版本的Python字节码中,有一个BREAK_LOOP指令,你可以研究一下这条指令,然后思考为什么新版本的虚拟机中不需要这条指令了。期待你把你的答案分享到评论区,如果你觉得这节课的内容对你有帮助的话,也欢迎你分享给其他朋友。我们下节课再见!

最近更新