流程控制語句 For、While 是怎么實現的?
楔子
在介紹 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 只能向前跳轉,循環還可以向后跳轉。