iT邦幫忙

2024 iThome 鐵人賽

DAY 30
0
Python

為你自己讀 CPython 原始碼系列 第 30

Day 30 - 例外處理的幕後真相

  • 分享至 

  • xImage
  •  

本文同步刊載於 「為你自己學 Python - 例外處理的幕後真相)

例外處理的幕後真相

為你自己學 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 - 例外處理的幕後真相)


上一篇
Day 29 - 無所不在的描述器
下一篇
Day 31 - 完賽 :)
系列文
為你自己讀 CPython 原始碼31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言