流程控制语句 for、while 是怎么实现的?

文摘   2024-11-05 10:39   北京  

楔子


在介绍 if 语句的时候,我们看到了最基本的控制流,其核心就是跳转。但无论是 if 还是 match,都只能向前跳转。而接下来介绍的 for、while 循环,指令是可以回退的,也就是向后跳转。

另外在 if 语句的分支中,无论哪个分支,其指令的跳跃距离都是当前指令与目标指令的距离,相当于向前跳了多少步。那么指令回退时,是不是相当于向后跳了多少步呢?带着疑问,我们开始今天的内容。



for 控制流


我们看一个简单的 for 循环的字节码。

import dis

code_string = """
lst = [1, 2]
for item in lst:
    print(item)
"""


dis.dis(compile(code_string, "<file>""exec"))

反编译之后,字节码指令如下。

      0 RESUME                   0
      # 加载常量 1,压入运行时栈
      2 LOAD_CONST               0 (1)
      # 加载常量 2,压入运行时栈
      4 LOAD_CONST               1 (2)
      # 将运行时栈的元素弹出,构建长度为 2 的列表,并压入栈中
      6 BUILD_LIST               2
      # 将上一步构建的列表从栈顶弹出,并用符号 lst 与之绑定
      # 到此 lst = [1, 2] 便完成了
      8 STORE_NAME               0 (lst)
      
      # 从全局名字空间中加载 lst
     10 LOAD_NAME                0 (lst)
      # 获取对应的迭代器,即 iter(lst)
     12 GET_ITER
      # 开始 for 循环,将里面的元素依次迭代出来
      # 如果循环结束,跳转到偏移量为 38 的指令,即 END_FOR
>>   14 FOR_ITER                10 (to 38)
      # 用符号 item 和迭代出的元素进行绑定
     18 STORE_NAME               1 (item)

     20 PUSH_NULL
      # 对应 print(item)
     22 LOAD_NAME                2 (print)
     24 LOAD_NAME                1 (item)
     26 CALL                     1
     34 POP_TOP
      # 到此,一次遍历就结束了,那么向后跳转 12 个指令
      # 会来到偏移量为 14 的指令,进行下一次遍历
     36 JUMP_BACKWARD           12 (to 14)
      # 循环结束
>>   38 END_FOR
     40 RETURN_CONST             2 (None)

我们直接从 10 GET_ITER 开始看起,首先 for 循环遍历的对象必须是可迭代对象,然后会调用它的 __iter__ 方法,得到迭代器。再不断地调用迭代器的 __next__ 方法,一步一步将里面的值全部迭代出来,当出现 StopIteration 异常时,for 循环捕捉,最后退出。

另外,我们说 Python 里面是先有值,后有变量,for 循环也不例外。循环的时候,先将迭代器中的元素迭代出来,然后再让变量 item 指向。


因此包含 10 个元素的迭代器,需要迭代 11 次才能结束。因为 for 循环事先是不知道迭代 10 次就能结束的,它需要再迭代一次,发现没有元素可以迭代、并捕获抛出的 StopIteration 之后,才能结束。

for 循环遍历可迭代对象时,会先拿到对应的迭代器,那如果遍历的就是一个迭代器呢?答案是依旧调用 __iter__,只不过由于本身就是一个迭代器,所以返回的还是其本身。

将元素迭代出来之后,就开始执行 for 循环体的逻辑了。


执行完之后,通过 JUMP_BACKWARD 跳转到字节码偏移量为 14、也就是 FOR_ITER 的位置开始下一次循环。这里我们发现它没有跳到 GET_ITER 那里,所以可以得出结论,for 循环在遍历的时候只会创建一次迭代器。


下面来看指令对应的具体逻辑:

TARGET(GET_ITER) {
    // 获取栈顶元素,即上一步压入的列表指针
    PyObject *iterable = stack_pointer[-1];
    PyObject *iter;
    #line 2255 "Python/bytecodes.c"
    // 调用 PyObject_GetIter,获取对应的迭代器
    // 这个函数在介绍迭代器的时候已经说过了
    // 等价于 iter = type(iterable).__iter__(iterable)
    iter = PyObject_GetIter(iterable);
    #line 3216 "Python/generated_cases.c.h"
    Py_DECREF(iterable);
    #line 2258 "Python/bytecodes.c"
    if (iter == NULLgoto pop_1_error;
    #line 3220 "Python/generated_cases.c.h"
    // 将迭代器 iter 设置为栈顶元素
    stack_pointer[-1] = iter;
    DISPATCH();
}

当创建完迭代器之后,就正式进入 for 循环了。所以从 FOR_ITER 开始,进入了虚拟机层面上的 for 循环。

源代码中的 for 循环,在虚拟机层面也一定对应着一个相应的循环控制结构。因为无论进行怎样的变换,都不可能在虚拟机层面利用顺序结构来实现源码层面上的循环结构,这也可以看作是程序的拓扑不变性。


因此源代码是宏观的,虚拟机执行字节码是微观的,尽管两者的层级不同,但本质上等价的,是程序从一种形式到另一种形式的等价转换。

我们来看一下 FOR_ITER 指令对应的具体实现:

TARGET(FOR_ITER) {
    // ...
    // 从栈顶获取迭代器对象(指针)
    PyObject *iter = stack_pointer[-1];
    PyObject *next;
    #line 2304 "Python/bytecodes.c"
    // ...
    // 调用迭代器类型对象的 tp_iternext,将迭代器内的元素迭代出来
    next = (*Py_TYPE(iter)->tp_iternext)(iter);
    // 如果 next 为 NULL,说明迭代出现异常
    if (next == NULL) {
        // 如果异常还不是 StopIteration,那么跳转到 error 标签
        if (_PyErr_Occurred(tstate)) {
            if (!_PyErr_ExceptionMatches(tstate, PyExc_StopIteration)) {
                goto error;
            }
            monitor_raise(tstate, frame, next_instr-1);
            _PyErr_Clear(tstate);
        }
        // 否则说明是 StopIteration,那么证明迭代完毕
        Py_DECREF(iter);
        STACK_SHRINK(1);
        /* Jump forward oparg, then skip following END_FOR instruction */
        // 跳转到 END_FOR 标签
        JUMPBY(INLINE_CACHE_ENTRIES_FOR_ITER + oparg + 1);
        DISPATCH();
    }
    #line 3297 "Python/generated_cases.c.h"
    // 到这里说明 next != NULL,证明迭代出元素了,那么压入运行时栈
    STACK_GROW(1);
    stack_pointer[-1] = next;
    next_instr += 1;
    DISPATCH();
}

在将迭代出来的元素压入运行时栈之后,会执行 STORE_NAME。然后虚拟机将沿着字节码指令的顺序一条一条地执行下去,从而完成输出的动作。

但是我们知道,for 循环中肯定会有指令回退的动作。从字节码中也看到了,for 循环遍历一次之后,会再次跳转到 FOR_ITER,而跳转所使用的指令就是 JUMP_BACKWARD。

TARGET(JUMP_BACKWARD) {
    PREDICTED(JUMP_BACKWARD);
    #line 2151 "Python/bytecodes.c"
    assert(oparg < INSTR_OFFSET());
    JUMPBY(-oparg);
    #line 3033 "Python/generated_cases.c.h"
    CHECK_EVAL_BREAKER();
    DISPATCH();
}

我们看到它调用了 JUMPBY,这个宏在介绍 if 语句的时候说过。

// Python/ceval_macros.h

// 从字节码的起始位置向前跳转 x 个指令
#define JUMPTO(x)       (next_instr = _PyCode_CODE(frame->f_code) + (x))
// 从 next_instr 处(指向当前待执行的指令)向前跳转 x 个指令
#define JUMPBY(x)       (next_instr += (x))

因为参数是 -oparg,所以相当于向后跳转了 oparg 个指令,从而实现指令回退,继续下一轮循环。

但天下没有不散的宴席,随着迭代的进行,for 循环总有退出的那一刻,而这个退出的动作只能落在 FOR_ITER 的身上。在 FOR_ITER 指令执行的过程中,如果遇到了 StopIteration,就意味着迭代结束了。

这个结果将导致虚拟机会将迭代器从运行时栈中弹出,同时执行一个 JUMPBY 动作,向前跳跃,在字节码的层面是向下,也就是偏移量增大的方向。



while 控制流



看完了 for,再来看看 while,并且我们还要分析两个关键字:break、continue。

import dis

code_string = """
a = 0
while a < 10:
    a += 1
    if a == 5:
        continue
    if a == 7:
        break
    print(a)
"""


dis.dis(compile(code_string, "<file>""exec"))

看一下它的指令:

      0 RESUME                   0
       
      # a = 0
      2 LOAD_CONST               0 (0)
      4 STORE_NAME               0 (a)
      
      # 比较 a < 10
>>    6 LOAD_NAME                0 (a)
      8 LOAD_CONST               1 (10)
     10 COMPARE_OP               2 (<)
      # 如果 a < 10 为假,说明循环结束
      # 跳转到偏移量为 80 的指令
     14 POP_JUMP_IF_FALSE       32 (to 80)
      
      # 到这里说明 while 条件成立,进入循环体
      # 执行 a += 1
>>   16 LOAD_NAME                0 (a)
     18 LOAD_CONST               2 (1)
     20 BINARY_OP               13 (+=)
     24 STORE_NAME               0 (a)
      
      # 比较 a == 5
     26 LOAD_NAME                0 (a)
     28 LOAD_CONST               3 (5)
     30 COMPARE_OP              40 (==)
      # 如果 a == 5 为假,跳转到偏移量为 38 的指令
     34 POP_JUMP_IF_FALSE        1 (to 38)
      # 否则说明 a == 5 为真,执行 continue
      # 由于 continue 是立即进入下一轮循环
      # 所以向后跳转到偏移量为 6 的指令
      # 所以在虚拟机的层面,continue 就是一个跳转指令
     36 JUMP_BACKWARD           16 (to 6)
      
      # 比较 a == 7
>>   38 LOAD_NAME                0 (a)
     40 LOAD_CONST               4 (7)
     42 COMPARE_OP              40 (==)
      # 如果 a == 7 为假,跳转到偏移量为 50 的指令
     46 POP_JUMP_IF_FALSE        1 (to 50)
      # 否则说明 a == 7 为真,执行 break
      # 而 break 是跳出循环,并且循环的下面也没有代码了
      # 所以直接 RETURN_CONST
     48 RETURN_CONST             5 (None)
      
      # print(a)
>>   50 PUSH_NULL
     52 LOAD_NAME                1 (print)
     54 LOAD_NAME                0 (a)
     56 CALL                     1
     64 POP_TOP
      
      # print(a) 结束后应该跳转到 while a < 10 处,进行下一轮循环
      # 但这里又执行了 a < 10
     66 LOAD_NAME                0 (a)
     68 LOAD_CONST               1 (10)
     70 COMPARE_OP               2 (<)
     74 POP_JUMP_IF_FALSE        1 (to 78)
      # 如果为假,然后跳转到 a += 1 处,因此整体效果和直接跳转到 while 处是等价的
     76 JUMP_BACKWARD           31 (to 16)
>>   78 RETURN_CONST             5 (None)
>>   80 RETURN_CONST             5 (None)

有了 for 循环,再看 while 循环就简单多了,整体逻辑和 for 高度相似,当然里面还结合了 if。

刚才说了,尽管源代码和字节码的层级不同,但本质上是等价的,是程序从一种形式到另一种形式的等价转换。在源码中能看到的,在字节码当中也能看到。比如源代码中的 continue 会跳转到循环所在位置,那么在字节码中自然也会对应一个跳转指令。



小结



以上我们就探讨了 Python 的两种循环,总的来说没什么难度,本质上还是跳转。只不过相对 if 只能向前跳转,循环还可以向后跳转。

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