跳至主要内容

例外處理的幕後真相

為你自己學 Python

電腦程式可能不會犯錯,但人類會,而且總是犯錯。有些是故意的,有些是無心的,有些則是無法預期的狀況。不管是哪種情況,我們都需要一個機制來處理這些問題,這就是例外處理(Exception Handling)的用途。

大部份程式語言都有類似的設計,在 Python 是使用 try...except... 關鍵字來處理例外,如果對於在 Python 怎麼使用 try...except... 有興趣,可以參考「為你自己學 Python」的錯誤處理章節介紹。這個章節我們要來看看例外處理的幕後真相,也就是在 CPython 裡面例外處理是怎麼實作的。

例外處理

我們先來個簡單的:

try:
1 / 0 # 這行會出錯
print("Hello World")
except Exception as e:
print(f"出事了阿伯! {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 指令在做什麼事?從名字看起來有點像是要把例外的資訊推到堆疊上,追一下原始碼:

檔案:Python/bytecodes.c
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 是什麼?我們來看看這個結構:

檔案:Include/cpython/pystate.h
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("正數")
else:
print("負數")

編譯出來的 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 ('負數')
40 CALL 1
// ... 略 ...

會根據計算結果「跳轉」到不同的指令。有點離題了,回來原本的主題。

比對例外種類

我們現在知道 BINARY_OP 指令在執行的時候會出錯,根據 ExceptionTable 知道該跳轉到第 32 行,然後把例外堆到例外堆疊裡後,就會繼續往下執行:

  4          34 LOAD_NAME                1 (Exception)
36 CHECK_EXC_MATCH
38 POP_JUMP_IF_FALSE 21 (to 82)
40 STORE_NAME 2 (e)

這時候已經進到 except 區塊了,先看看這個沒看過的 CHECK_EXC_MATCH 指令:

檔案:Python/bytecodes.c
inst(CHECK_EXC_MATCH, (left, right -- left, b)) {
assert(PyExceptionInstance_Check(left));
if (check_except_type_valid(tstate, right) < 0) {
DECREF_INPUTS();
ERROR_IF(true, error);
}

int res = PyErr_GivenExceptionMatches(left, right);
DECREF_INPUTS();
b = res ? Py_True : Py_False;
}

在這裡的 left 指的是捕獲的例外實體,以這個例子來說是會是 ZeroDivisionError 類別的實體。right 指的是想要捕獲的例外,以我們的範例來說就是 Exception。在這個指令裡會比對 left 是不是一種 right,如果是就會回傳 Py_True,否則回傳 Py_False,這剛好就對應到 Python 的 TrueFalse。另外,left 還是留在堆疊上,等下一個指令使用。

通常教課書上(包括我寫的書也是)都會教你處理例外應該要精準的抓到是什麼類型的例外,應該要寫成 except ZeroDivisionError as e: 比較好,但我在這個範例偷懶沒這麼做,所以這裡的 left 會是當前的例外值也就是 ZeroDivisionErrorrighte,這兩個比對之後 ,經過判斷之後,b 應該會是 Py_True,也就是 Python 裡的 True

是說,算這個真假值可以做什麼?下一個指令 POP_JUMP_IF_FALSE 21 光看名字不用看實作大概就能猜到是什麼意思了:

檔案:Python/bytecodes.c
inst(POP_JUMP_IF_FALSE, (cond -- )) {
if (Py_IsFalse(cond)) {
JUMPBY(oparg);
}
else if (!Py_IsTrue(cond)) {
int err = PyObject_IsTrue(cond);
DECREF_INPUTS();
if (err == 0) {
JUMPBY(oparg);
}
else {
ERROR_IF(err < 0, error);
}
}
}

因為剛才我們算出來的是 False,所以會跳到指定的指令,以這裡的範例是 RERAISE 0。這個指令是用來重新拋出例外的,這樣就可以讓下一個例外處理區塊繼續處理這個例外。不過因為我們算出來 True,所以繼續往下走,把剛才的 CHECK_EXC_MATCH 指令裡的 left 存在變數 e 裡。

面對它、處理它

繼續往下:

  5          42 PUSH_NULL
44 LOAD_NAME 0 (print)
46 LOAD_CONST 3 ('出事了阿伯! ')
48 LOAD_NAME 2 (e)
50 FORMAT_VALUE 0
52 BUILD_STRING 2
54 CALL 1
62 POP_TOP
64 POP_EXCEPT
66 LOAD_CONST 4 (None)
68 STORE_NAME 2 (e)
70 DELETE_NAME 2 (e)
72 RETURN_CONST 4 (None)
>> 74 LOAD_CONST 4 (None)

這段指令上半部是在組裝字串,這裡有個我們沒看過的指令 POP_EXCEPT

檔案:Python/bytecodes.c
inst(POP_EXCEPT, (exc_value -- )) {
_PyErr_StackItem *exc_info = tstate->exc_info;
Py_XSETREF(exc_info->exc_value, exc_value);
}

這兩行還滿單純的,就是取得目前執行緒的例外堆疊,然後把 exc_value 定為剛剛處理完的例外值。Py_XSETREF 這個巨集會把新的 exc_value 設定給 exc_info->exc_value,然後放掉原本的 exc_value。以結果來說,就是把例外堆疊裡面的例外值換成剛剛處理完的例外值,等於是把最上層的例外拿掉,讓下一個例外處理區塊可以處理(如果有的話)。

一個 try...except... 的實作流程差不多大概就是這樣,不算太複雜...吧 :)

終於!

我再把原本的範例再加上一個 finally 區塊:

try:
1 / 0 # 這行會出錯
print("Hello World")
except Exception as e:
print(f"出事了阿伯! {e}")
finally:
print("鐵人賽完賽啦!就是要為你自己學 Python!")

在 Python 裡的 finally 區塊是一定會執行的,不管有沒有例外都會執行。來看看目前的 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

7 >> 30 PUSH_NULL
32 LOAD_NAME 0 (print)
34 LOAD_CONST 5 ('鐵人賽完賽啦!就是要為你自己學 Python!')
36 CALL 1
44 POP_TOP
46 RETURN_CONST 4 (None)
>> 48 PUSH_EXC_INFO

這裡基本上都差不多,連 finally 的區塊也都一起在這裡,不過在第 30 個 Bytecode 前面多了一個 >> 標記,至這個標記的位置並不在 ExceptionTable 裡,這是因為 finally 區塊是一定會執行的,所以不會也不需要在 ExceptionTable 進行跳轉。

繼續往下看:

  5          58 PUSH_NULL
60 LOAD_NAME 0 (print)
62 LOAD_CONST 3 ('出事了阿伯! ')
64 LOAD_NAME 2 (e)
66 FORMAT_VALUE 0
68 BUILD_STRING 2
70 CALL 1
78 POP_TOP
80 POP_EXCEPT
82 LOAD_CONST 4 (None)
84 STORE_NAME 2 (e)
86 DELETE_NAME 2 (e)
88 JUMP_BACKWARD 30 (to 30)
>> 90 LOAD_CONST 4 (None)

這裡只有 JUMP_BACKWARD 指令是新的,這個指令是用來跳回到指定的指令,這裡是跳回到第 30 行,正好也就是 finally 區塊的開始。這樣一來,不管有沒有發生例外,finally 區塊都會執行。

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!