流程控制語句 if 是怎么實現的?
楔子
前面我們分析了虛擬機執行字節碼的原理,并且也介紹了不少指令,但這些指令都是從上往下順序執行的,不涉及任何的跳轉。而像流程控制語句,比如 if、for、while、try 等等,它們在執行時會發生跳轉,因此 Python 底層一定還存在相應的跳轉指令。
那么從現在開始,就來分析一下這些流程控制語句的實現原理,本文先來介紹 if 語句。
if 字節碼
if 語句應該是最簡單也是最常用的流程控制語句,那么它的字節碼是怎么樣的呢?當然這里的 if 語句指的是 if elif else 整體,里面的某個條件叫做該 if 語句的分支。
我們看一下 if 語句的字節碼長什么樣子。
import dis
code_string = """
score = 90
if score >= 85:
print("Good")
elif score >= 60:
print("Normal")
else:
print("Bad")
"""
dis.dis(compile(code_string, "<file>", "exec"))
反編譯得到的字節碼指令比較多,我們來慢慢分析。另外為了閱讀方便,源代碼行號就不顯示了。
0 RESUME 0
# 加載常量 90 并壓入運行時棧
2 LOAD_CONST 0 (90)
# 加載符號表中索引為 0 的符號 "score"
# 彈出運行時棧的棧頂元素 90
# 然后將兩者綁定起來,存放在當前的名字空間中
4 STORE_NAME 0 (score)
# 加載變量 score
6 LOAD_NAME 0 (score)
# 加載常量 85
8 LOAD_CONST 1 (85)
# 進行比較,操作符是 >=,這個指令之前介紹過的
10 COMPARE_OP 92 (>=)
# 如果比較結果為 False,就進行跳轉,從名字也能看出指令的含義
# 那么跳轉到什么地方呢?指令參數 9 表示向前跳轉 9 個指令
# 根據后面的提示,我們看到會跳轉到偏移量為 34 的指令
# 很明顯,就是當前分支的下一個分支。關于具體是怎么跳轉的,一會兒說
14 POP_JUMP_IF_FALSE 9 (to 34)
# 如果走到這里說明沒有跳轉,當前分支的條件為真
# 那么開始執行該分支內部的邏輯
# PUSH_NULL 指令做的事情很簡單,就是往棧里 PUSH 一個 NULL
16 PUSH_NULL
# 以下 4 條指令對應 print("Good")
18 LOAD_NAME 1 (print)
20 LOAD_CONST 2 ('Good')
22 CALL 1
30 POP_TOP
# if 語句只有一個分支會被執行
# 如果執行了某個分支,那么整個 if 語句就結束了
32 RETURN_CONST 6 (None)
# 對應 score >= 60
>> 34 LOAD_NAME 0 (score)
36 LOAD_CONST 3 (60)
38 COMPARE_OP 92 (>=)
# 如果比較結果為假,跳轉到偏移量為 62 的指令
42 POP_JUMP_IF_FALSE 9 (to 62)
# 和上面類似
44 PUSH_NULL
46 LOAD_NAME 1 (print)
48 LOAD_CONST 4 ('Normal')
50 CALL 1
58 POP_TOP
60 RETURN_CONST 6 (None)
# 最后一個是 else 分支,而 else 分支沒有判斷條件
# 邏輯依舊和上面類似
>> 62 PUSH_NULL
64 LOAD_NAME 1 (print)
66 LOAD_CONST 5 ('Bad')
68 CALL 1
76 POP_TOP
78 RETURN_CONST 6 (None)
我們看到字節碼偏移量之前有幾個 >> 這樣的符號,顯然這是 if 語句中的每一個分支開始的地方。
經過分析,整個 if 語句的字節碼指令還是很簡單的。就是從上到下依次判斷每一個分支,如果某個分支條件成立,就執行該分支的代碼,執行完畢后結束整個 if 語句;否則跳轉到下一個分支。
顯然核心就在于 POP_JUMP_IF_FALSE 指令,我們看一下它的邏輯。
POP_JUMP_IF_FALSE
COMPARE_OP 執行完之后會將比較的結果壓入運行時棧,而該指令則是將結果從棧頂彈出并判斷真假。如果為假,那么跳到下一個分支,否則執行此分支的代碼。
TARGET(POP_JUMP_IF_FALSE) {
PREDICTED(POP_JUMP_IF_FALSE);
// 從棧頂彈出比較結果,當然這里其實只是獲取
// 如果再結合最下面的 STACK_SHRINK(1),那么等價于彈出
PyObject *cond = stack_pointer[-1];
#line 2157 "Python/bytecodes.c"
// 如果 cond is False,那么 Py_IsFalse(cond) 就是真
// 此時會通過 JUMPBY 跳轉到 if 語句的下一個分支,關于 JUMPBY 一會兒介紹
if (Py_IsFalse(cond)) {
JUMPBY(oparg);
}
// 但對于 if 語句來說,除了 False 之外,像 None、0、""、[] 等也表示假
// 那么當 cond 也不是 True 的時候(說明不是布爾值),要繼續判斷
else if (!Py_IsTrue(cond)) {
// Py_IsTrue(cond):等價于 Python 的 cond is True
// PyObject_IsTrue(cond):等價于 Python 的 bool(cond) is True
// 所以 if cond 和 if bool(cond) 是等價的
int err = PyObject_IsTrue(cond);
#line 3047 "Python/generated_cases.c.h"
Py_DECREF(cond);
#line 2163 "Python/bytecodes.c"
// 如果 PyObject_IsTrue 返回 0,說明 bool(cond) 不是 True
// 即 cond 為假,意味著條件不成立,那么要跳轉到 if 語句的下一個分支
if (err == 0) {
JUMPBY(oparg);
}
// 如果返回值小于 0,說明出錯了(基本不會發生)
// 由于運行時棧里面還有一個元素
// 那么跳轉到幀評估函數中的 pop_1_error
else {
if (err < 0) goto pop_1_error;
}
}
#line 3057 "Python/generated_cases.c.h"
STACK_SHRINK(1);
DISPATCH();
}
邏輯不難理解,但是里面出現了幾個判斷布爾值的函數,我們補充一下。
// Objects/object.c
// 等價于 Python 的 x is y
int Py_Is(PyObject *x, PyObject *y)
{
return (x == y);
}
// 等價于 Python 的 x is None
int Py_IsNone(PyObject *x)
{
return Py_Is(x, Py_None);
}
// 等價于 Python 的 x is True
int Py_IsTrue(PyObject *x)
{
return Py_Is(x, Py_True);
}
// 等價于 Python 的 x is False
int Py_IsFalse(PyObject *x)
{
return Py_Is(x, Py_False);
}
// 等價于 Python 的 bool(v) is True
int
PyObject_IsTrue(PyObject *v)
{
Py_ssize_t res;
// 如果 v 本身就是布爾值 True,返回 1
if (v == Py_True)
return 1;
// 如果 v 本身就是布爾值 False,返回 0
if (v == Py_False)
return 0;
// 如果 v 是 None,返回 0
if (v == Py_None)
return 0;
// 如果 v 是數值型對象,并且實現了 nb_bool(對應 __bool__)
// 那么調用,如果結果不為 0,返回 1,否則返回 0
else if (Py_TYPE(v)->tp_as_number != NULL &&
Py_TYPE(v)->tp_as_number->nb_bool != NULL)
res = (*Py_TYPE(v)->tp_as_number->nb_bool)(v);
// 如果 v 是映射型對象,并且實現了 mp_length(對應 __len__)
// 那么調用,返回對象的長度
else if (Py_TYPE(v)->tp_as_mapping != NULL &&
Py_TYPE(v)->tp_as_mapping->mp_length != NULL)
res = (*Py_TYPE(v)->tp_as_mapping->mp_length)(v);
// 如果 v 是序列型對象,并且實現了 sq_length(對應 __len__)
// 那么調用,返回對象的長度
else if (Py_TYPE(v)->tp_as_sequence != NULL &&
Py_TYPE(v)->tp_as_sequence->sq_length != NULL)
res = (*Py_TYPE(v)->tp_as_sequence->sq_length)(v);
// 如果以上條件都不滿足,直接返回 1,比如自定義類的實例對象(默認為真)
else
return 1;
// 如果 res > 0 返回 1,否則返回 0
return (res > 0) ? 1 : Py_SAFE_DOWNCAST(res, Py_ssize_t, int);
}
// 等價于 Python 的 not v
int
PyObject_Not(PyObject *v)
{
int res;
res = PyObject_IsTrue(v);
if (res < 0)
return res;
// 如果 v 是真,res == 1,那么 res == 0 結果是 0
// 如果 v 是假,res == 0,那么 res == 0 結果是 1
// 相當于取反
return res == 0;
}
// Objects/boolobject.c
static PyObject *
bool_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
// <class 'bool'> 是一個 Python 類,這里的 bool_new 便是它的構造函數
PyObject *x = Py_False;
long ok;
// 不接收關鍵字參數
if (!_PyArg_NoKeywords("bool", kwds))
return NULL;
// 只接收 0 ~ 1 個參數,如果不傳,那么默認返回 False
if (!PyArg_UnpackTuple(args, "bool", 0, 1, &x))
return NULL;
// 調用 PyObject_IsTrue,所以我們說 if v 和 if bool(v) 是等價的
// 因為當 v 不是布爾值時,if v 對應的指令內部會調用 PyObject_IsTrue
// 而 bool(v) 也會調用 PyObject_IsTrue,所以兩者是等價的
ok = PyObject_IsTrue(x);
if (ok < 0)
return NULL;
// 調用 PyBool_FromLong 創建布爾值,ok 為 1 返回 True,為 0 返回 False
return PyBool_FromLong(ok);
}
PyObject *PyBool_FromLong(long ok)
{
return ok ? Py_True : Py_False;
}
相信你現在明白了為什么 if 后面不跟布爾值也是可以的,因為有一個 C 函數 PyObject_IsTrue,可以判斷任意對象的真假。如果 if 后面跟著的不是布爾值,那么會自動調用該函數。另外由于 bool(v) 也會調用該函數,所以 if v 和 if bool(v) 是等價的。
注:沒有 PyObject_IsFalse。
說完了 POP_JUMP_IF_FALSE 指令,再補充一個和它相似的指令叫 POP_JUMP_IF_TRUE,它表示當比較結果為真時,跳到下一個分支,否則執行當前分支的代碼。可能有人覺得,這不對吧,比較結果為真,難道不應該執行當前分支的邏輯嗎?所以 POP_JUMP_IF_TRUE 指令似乎本身就是矛盾的。
仔細想想你應該能夠猜到原因,答案就是使用了 not。
import dis
code_string = """
if 2 > 1:
print("古明地覺")
"""
# 只打印部分字節碼
dis.dis(compile(code_string, "<file>", "exec"))
"""
2 LOAD_CONST 0 (2)
4 LOAD_CONST 1 (1)
6 COMPARE_OP 68 (>)
10 POP_JUMP_IF_FALSE 9 (to 30)
"""
code_string = """
if not 2 > 1:
print("古明地覺")
"""
dis.dis(compile(code_string, "<file>", "exec"))
"""
2 LOAD_CONST 0 (2)
4 LOAD_CONST 1 (1)
6 COMPARE_OP 68 (>)
10 POP_JUMP_IF_TRUE 9 (to 30)
"""
正常情況下如果比較結果為 False,則跳轉到 if 語句的下一個分支,所以 POP_JUMP_IF_FALSE 指令是合理的。至于 POP_JUMP_IF_TRUE 指令從邏輯上似乎就不該存在,因為它和 if 語句本身是相矛盾的。
但現在我們明白了,該指令其實是為 not 關鍵字準備的。如果比較結果為真,那么 not 取反就是假,于是跳轉到 if 語句的下一個分支,所以整個邏輯依舊是正確的。
當然這里只有一個 not,即使有很多個 not 也是可以的,盡管這沒太大意義。
import dis
# 這里有 4 個 not,因為是偶數個,兩兩相互抵消
# 所以結果等價于 if 2 > 1
code_string = """
if not not not not 2 > 1:
print("古明地覺")
"""
# 只打印部分字節碼
dis.dis(compile(code_string, "<file>", "exec"))
"""
2 LOAD_CONST 0 (2)
4 LOAD_CONST 1 (1)
6 COMPARE_OP 68 (>)
10 POP_JUMP_IF_FALSE 9 (to 30)
"""
# 這里有 5 個 not,因為是奇數個,兩兩相互抵消之后還剩下一個
# 所以結果等價于 if not 2 > 1
code_string = """
if not not not not not 2 > 1:
print("古明地覺")
"""
dis.dis(compile(code_string, "<file>", "exec"))
"""
2 LOAD_CONST 0 (2)
4 LOAD_CONST 1 (1)
6 COMPARE_OP 68 (>)
10 POP_JUMP_IF_TRUE 9 (to 30)
"""
然后再看一下 POP_JUMP_IF_TRUE 指令的內部邏輯,顯然它和 POP_JUMP_IF_FALSE 是類似的。
TARGET(POP_JUMP_IF_TRUE) {
// 獲取棧頂元素
PyObject *cond = stack_pointer[-1];
#line 2173 "Python/bytecodes.c"
// 如果 cond is True,跳轉到 if 語句的下一個分支
if (Py_IsTrue(cond)) {
JUMPBY(oparg);
}
// 如果 cond 不是 True,那么看 bool(cond) 是否為 True
else if (!Py_IsFalse(cond)) {
int err = PyObject_IsTrue(cond);
#line 3070 "Python/generated_cases.c.h"
Py_DECREF(cond);
#line 2179 "Python/bytecodes.c"
// err > 0,說明結果為真,跳轉到 if 語句的下一個分支
if (err > 0) {
JUMPBY(oparg);
}
// 否則說明比較出錯了(基本不會發生)
else {
if (err < 0) goto pop_1_error;
}
}
#line 3080 "Python/generated_cases.c.h"
STACK_SHRINK(1);
DISPATCH();
}
以上就是 POP_JUMP_IF_FALSE 和 POP_JUMP_IF_TRUE 的內部邏輯,可以說非常簡單。
JUMPBY
指令跳轉是由 JUMPBY 實現的,它內部的邏輯長啥樣呢?
// Python/ceval_macros.h
#define JUMPBY(x) (next_instr += (x))
字節碼指令的遍歷是通過 next_instr 實現的,如果將指令執行的方向代表前進的方向,顯然它表示從當前指令所在的位置向前跳轉 x 個指令。
圖片
POP_JUMP_IF_FALSE 指令的偏移量為 14,向前跳轉 9 個指令,一個指令的大小是 2 字節,所以結果是 14 + 18 = 32。咦,不是應該跳轉到偏移量為 34 的指令嗎,為啥結果是 32 呢?
很簡單,TARGET 是一個宏,它在展開之后,還會對 next_instr 做一次自增操作。
圖片
除了 JUMPBY 之外,還有一個 JUMPTO,它表示從字節碼的起始位置向前跳轉 x 個指令。
// 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))
所以 JUMPTO 表示絕對跳轉,JUMPBY 表示相對跳轉。不難發現,JUMPTO 既可以向前跳轉(偏移量增大),也可以向后跳轉(偏移量減小);而 JUMPBY 只能向前跳轉。
假設參數為 n,當前指令的偏移量為 m。對于 JUMPTO 而言,跳轉之后的偏移量始終為 2n,如果 m < 2n 就是向前跳轉,m > 2n 就是向后跳轉。但對于 JUMP_BY 而言,由于它是從當前待執行的指令開始跳轉的,所以只能向前跳轉(偏移量增大)。
指令預測
CPython 3.12 里面引入了計算跳轉,可以避免不必要的匹配。因為整個指令集合是已知的,這就說明某條指令在執行時,便可知道它的下一條指令是什么。
所以當前指令處理完后,可以直接跳轉到下一條指令對應的處理邏輯中,這就是計算跳轉。但如果不使用計算跳轉,那么每次讀取到指令后,都要進入 switch,順序匹配數百個 case 分支,找到匹配成功的那一個。
因此使用計算跳轉可以避免不必要的匹配,既然提前知道下一條指令是啥了,那么直接精確跳轉就行,無需多走一遍 switch。不過要想實現計算跳轉,需要 GCC 支持標簽作為值,即 goto *label_addr 用法,由于 label_addr 是一個標簽地址,那么解引用之后就是標簽了。至于具體會跳轉到哪一個標簽,取決于 label_addr 保存了哪一個標簽的地址,因此這種跳轉是動態的,在運行時決定跳轉目標。
goto 標簽:靜態跳轉,標簽需要顯式地定義好,跳轉位置在編譯期間便已經固定。
goto *標簽地址:動態跳轉(計算跳轉),跳轉位置不固定,可以是已有標簽中的任意一個。至于具體是哪一個,需要在運行時經過計算才能確定。
虛擬機為每個指令的處理邏輯都定義了一個標簽,對于計算跳轉來說,goto 的結果是 *標簽地址,這個地址是運行時計算得出的。我們舉個例子,隨便看一段字節碼指令集。
比如當前正在執行 LOAD_NAME 指令,那么下一條指令可以是 STORE_NAME、LOAD_NAME 以及 BUILD_LIST 等。當開啟計算跳轉時:
- 如果下一條指令是 STORE_NAME,那么之后就會跳轉 STORE_NAME 對應的標簽;
- 如果下一條指令是 LOAD_NAME,那么之后就會跳轉到 LOAD_NAME 對應的標簽;
- 如果下一條指令是 BUILD_LIST,那么之后就會跳轉到 BUILD_LIST 對應的標簽;
所以在運行時判斷指令的值,獲取對應的標簽,從而實現精確跳轉,這就是計算跳轉。當然這些內容在剖析虛擬機執行字節碼時已經說過了,這里再回顧一下。
接下來說一說指令預測,不難發現,如果是計算跳轉,那么指令預測功能貌似沒啥用,因為總是能精確跳轉到下一個指令對應的標簽中。沒錯,指令預測只有在不使用計算跳轉的情況下有用,那什么是指令預測呢?
在不使用計算跳轉時,goto 后面必須是一個靜態的標簽,跳轉位置在編譯階段便已經固定,換句話說一個指令執行完畢后要跳轉到哪一個標簽是寫死的,不能保證跳轉后的標簽正好對應下一條指令的處理邏輯。比如 LOAD_NAME 的下一條指令可以是 STORE_NAME 和 BUILD_LIST,那么應該跳轉到哪一個指令對應的標簽中呢?
正因為這種不確定性,絕大部分指令在執行完畢后都會直接跳轉到 switch 語句所在位置,然后順序匹配 case 分支。
但也有那么幾個指令,由于彼此的關聯性很強,很多時候都是成對出現的,面對這樣的指令,虛擬機會進行預測。比如 A 和 B 兩個指令的關聯性很強,盡管 A 的下一條指令除了是 B 之外,也有可能是其它指令,但 B 出現的概率是最大的,因此虛擬機會預測下一條指令是 B 指令。于是在執行完 A 指令之后,會驗證自己的預測是否正確,即檢測下一條指令是否是 B 指令。如果預測對了,可以實現精確跳轉,如果預測錯了,就只能回到 switch 語句逐一匹配 case 分支了。
總結一下:指令在執行時,它的下一條指令是已知的,但是不固定,有多種可能。如果不使用計算跳轉,由于 goto 后面必須是一個寫死的標簽,而下一條指令卻不固定,那么只能跳轉到 switch 語句所在的位置,順序匹配 case 分支。但也有那么幾對指令,關聯性很強,雖然不能保證百分百,但值得做一次嘗試,這便是指令預測。
當然啦,如果使用計算跳轉,情況則不一樣了,此時壓根用不到指令預測。因為 goto 后面是 *標簽地址,而地址是可以動態獲取的。由于所有標簽的地址都保存在了一個數組中,不管接下來要處理哪一條指令,都可以獲取到對應的標簽地址,實現精確跳轉。
以上有很多都是之前說過的內容,再重復一遍加深印象。好,關于指令預測我們已經知道是啥了,那么在源碼層面又是如何體現的呢?
在 POP_JUMP_IF_FALSE 指令中,我們看到有這么一行邏輯。
圖片
里面有一個宏 PREDICTED。
// Python/ceval_macros.h
#define PREDICTED(op) PREDICT_ID(op):
#define PREDICT_ID(op) PRED_##op
這個宏展開之后又是一個標簽,由于調用時結尾加了分號,所以這還是一個空標簽。整體效果如下:
圖片
那么它有什么用呢?我們再看一個指令就明白了。
圖片
MATCH_SEQUENCE 指令是做什么的,我們以后再說,總之虛擬機認為該指令執行完之后,大概率會執行 POP_JUMP_IF_FALSE 指令,所以做了一個預測。而相關邏輯位于 PREDICT 中,看一下它長什么樣子。
// Python/ceval_macros.h
#define PREDICT_ID(op) PRED_##op
// 如果開啟計算跳轉,那么指令預測不生效
// 因為本身就知道該跳轉到哪個指令對應的標簽
#if USE_COMPUTED_GOTOS
#define PREDICT(op) if (0) goto PREDICT_ID(op)
#else
// 如果不開啟計算跳轉,那么會比較預測的指令和實際的指令是否相等
// 所以 MATCH_SEQUENCE 指令處理邏輯里面的 PREDICT(POP_JUMP_IF_FALSE)
// 就是在判斷下一條指令是不是自己預測的 POP_JUMP_IF_FALSE
// 如果是,說明預測成功,那么 goto PRED_POP_JUMP_IF_FALSE
// 否則說明預測失敗,那么會執行 DISPATCH(),然后 goto 到 switch 語句所在位置
#define PREDICT(next_op) \
do { \
_Py_CODEUNIT word = *next_instr; \
opcode = word.op.code; \
if (opcode == next_op) { \
oparg = word.op.arg; \
INSTRUCTION_START(next_op); \
goto PREDICT_ID(next_op); \
} \
} while(0)
#endif
以上便是指令預測,說白了就是如果指令 A 和指令 B 具有極高的關聯性(甚至百分百),那么執行完 A 指令后會判斷下一條指令是不是 B。如果是,那么直接跳轉即可,就省去了匹配 case 分支的時間,如果不是,那就只能挨個匹配了。
因為是靜態跳轉,goto 后面的標簽是寫死的,編譯階段就確定了,所以只有那種關聯度極高的指令才會開啟預測功能,因為預測成功的概率比較高。但如果指令 A 的下一條指令有多種可能(假設有 6 種),并且每種指令出現的概率還差不多,那么這時不管預測哪一個,成功的概率都只有 1/6。顯然這就不叫預測了,這是在擲骰子,因此對于這樣的指令,虛擬機不會為它開啟預測功能。
圖片
比如 LOAD_NAME 的下一個指令可以是 STORE_NAME、LOAD_NAME、BUILD_LIST 等等,不管預測哪一種,成功的概率都不是特別高,因此它沒有進行指令預測。
所以就一句話:只有 A 和 B 兩個指令的關聯度極高的時候,執行 A 之后才會預測下一條指令是否是 B。預測成功直接跳轉,預測失敗執行 DISPATCH(),跳轉到 switch 語句所在位置,即 dispatch_opcode 標簽。
但如果使用了計算跳轉,情況就不一樣了,此時不會開啟指令預測,或者說指令預測里的邏輯會變得無效。
圖片
很明顯,使用計算跳轉后,PREDICT(op) 不會產生任何效果,因此也可以理解為沒有開啟指令預測。而之所以不用預測,是因為執行 DISPATCH() 的時候,本身就可以精確跳轉到指定位置。
小結
本篇文章我們就分析了 if 語句的實現原理,總的來說不難理解。依舊是在棧楨中執行字節碼,只是多了一個指令跳轉罷了,至于怎么跳轉、跳轉到什么地方,全部都體現在字節碼中。