古明地觉的编程教室 05月14日 15:42
流程控制语句 for、while 是怎么实现的?
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入分析了Python中for和while循环的底层实现机制,通过反编译字节码,揭示了循环结构中指令的跳转行为。文章详细解读了FOR_ITER、JUMP_BACKWARD等关键指令,阐述了迭代器的工作原理以及循环的结束条件。此外,还分析了break和continue关键字在while循环中的作用,以及它们对应的字节码指令。通过对字节码的剖析,帮助读者理解Python循环控制流的本质。

🔄 **For 循环的迭代机制**:For 循环通过 GET_ITER 指令获取可迭代对象的迭代器,并使用 FOR_ITER 指令不断迭代元素。当迭代器抛出 StopIteration 异常时,循环结束。

◀️ **JUMP_BACKWARD指令的作用**:在循环过程中,JUMP_BACKWARD 指令用于实现指令回退,从而实现循环的重复执行。该指令通过向后跳转指定的偏移量,使程序回到循环的起始位置。

🚪 **Break 和 Continue 的实现原理**:Break 语句通过 RETURN_CONST 指令直接跳出循环,而 Continue 语句则通过 JUMP_BACKWARD 指令跳转到循环的起始位置,从而进入下一次循环迭代。

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

原创 古明地觉 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 只能向前跳转,循环还可以向后跳转。

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Python 循环控制流 字节码 迭代器
相关文章