例外處理的幕後真相

電腦程式可能不會犯錯,但人類會,而且總是犯錯。有些是故意的,有些是無心的,有些則是無法預期的狀況。不管是哪種情況,我們都需要一個機制來處理這些問題,這就是例外處理(Exception Handling)的用途。
大部份程式語言都有類似的設計,在 Python 是使用 try...except... 關鍵字來處理例外,這個章節我們要來看看例外處理的幕後真相,也就是在 CPython 裡面例外處理是怎麼實作的。
例外處理
我們先來個簡單的:
try:
1 / 0 # 這行會出錯
print("Hello World")
except Exception as e:
print(f"Something went wrong! {e}")
執行上面這段程式,因為 1/0 會得到 ZeroDivisionError 例外,然後被 except 捕捉、處理。這是怎麼做到的?我們來看看這段程式碼編譯出來的 Bytecode 長什麼樣子,因為行數有點多,我就分段來說明:
2 4 LOAD_CONST 0 (1)
6 LOAD_CONST 1 (0)
8 BINARY_OP 11 (/)
12 POP_TOP
3 14 PUSH_NULL
16 LOAD_NAME 0 (print)
18 LOAD_CONST 2 ('Hello World')
20 CALL 1
28 POP_TOP
30 RETURN_CONST 4 (None)
>> 32 PUSH_EXC_INFO
// ... 略 ...
這段看起來滿簡單的,除了最後一行的 PUSH_EXC_INFO 之外沒有什麼新指令,不難看出就是在 try 區塊裡的那兩行程式碼。雖然我們知道在 BINARY_OP 指令做除法運算的時候應該要出錯,但那是執行時候的事,編譯 Bytecode 階段只是把這個指令編譯進去而已。除非是語法錯誤造成編譯失敗,不然編譯階段不會知道執行時候會不會或該不該出錯,直到 Python 的 VM 執行這段 Bytecode 才會知道。
堆堆堆堆疊
那麼 PUSH_EXC_INFO 指令在做什麼事?從名字看起來有點像是要把例外的資訊推到堆疊上,追一下原始碼:
inst(PUSH_EXC_INFO, (new_exc -- prev_exc, new_exc)) {
_PyErr_StackItem *exc_info = tstate->exc_info;
if (exc_info->exc_value != NULL) {
prev_exc = exc_info->exc_value;
}
else {
prev_exc = Py_None;
}
assert(PyExceptionInstance_Check(new_exc));
exc_info->exc_value = Py_NewRef(new_exc);
}
這個指令是把新的例外 new_exc 保存到當前執行緒的例外堆疊中 exc_info->exc_value。如果本來就有異常還沒處理就先把它拿出來放到 prev_exc 以備將來使用。
為什麼會有例外堆疊這種東西?因為在例外處理不是百分百都能把問題解決,萬一在 except 區塊處理到一半也是可以繼續出包,然後丟給下一個例外處理區塊,萬一又出包就繼續這樣一路疊下去,所以有這個堆疊好像也是合理。這個 _PyErr_StackItem 是什麼?我們來看看這個結構:
typedef struct _err_stackitem {
PyObject *exc_value;
struct _err_stackitem *previous_item;
} _PyErr_StackItem;
這裡可以看到除了剛才看到的 exc_value 成員之外,還有另一個成員 previous_item 指向前一個例外堆疊,這樣就可以把例外堆疊串起來,讓我們可以在例外處理的時候一層一層的處理。
例外表
原本應該是繼續往下看,不過這裡有一些「變化」,我先拉到 Bytecode 最下面,看一個之前沒看過的 ExceptionTable:
ExceptionTable:
4 to 28 -> 32 [0]
32 to 40 -> 84 [1] lasti
42 to 62 -> 74 [1] lasti
74 to 82 -> 84 [1] lasti
雖然沒看過,不過意思還算滿容易理解的,4 to 28 -> 32 [0] 表示從 Bytecode 的第 4 行到第 28 行之間的指令出錯的話,就跳到第 32 行指令。同理,其它三行也都是差不多的意思。而在 32 [0]、74 [1] 以及 84[1] 後面看起來像是索引值的是指不同的例外處理,例如在同一個 try 可能會接好幾種 except,這樣就可以知道是哪一個例外處理區塊要處理這個例外。
傳送門
而在第 32 行的 PUSH_EXC_INFO、第 74 行的 LOAD_CONST 以及第 84 行的 COPY 指令前面都有個 >> 標記,其實 82 行的 RERAISE 也有,但這個晚點再說。這個 >> 標記的意思是指這是一個「傳送門」,以我們上面的範例來說是一個例外處理區塊的開始。不只是只有 try...except... 會有 >> 標記,一般的 if...else... 也會有,例如:
a = 100
if a > 0:
print("Positive")
else:
print("Negative")
編譯出來的 Bytecode 會像這樣:
// ... 略 ...
2 6 LOAD_NAME 0 (a)
8 LOAD_CONST 1 (0)
10 COMPARE_OP 68 (>)
14 POP_JUMP_IF_FALSE 9 (to 34)
// ... 略 ...
5 >> 34 PUSH_NULL
36 LOAD_NAME 1 (print)
38 LOAD_CONST 3 ('Negative')
40 CALL 1
// ... 略 ...
會根據計算結果「跳轉」到不同的指令。有點離題了,回來原本的主題。