本文同步刊載於 「為你自己學 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 的 True
跟 False
。另外,left
還是留在堆疊上,等下一個指令使用。
通常教課書上(包括我寫的書也是)都會教你處理例外應該要精準的抓到是什麼類型的例外,應該要寫成 except ZeroDivisionError as e:
比較好,但我在這個範例偷懶沒這麼做,所以這裡的 left
會是當前的例外值也就是 ZeroDivisionError
,right
是 e
,這兩個比對之後
,經過判斷之後,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 - 例外處理的幕後真相)」