楔子
在介绍 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 == NULL) goto 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 只能向前跳转,循环还可以向后跳转。