2.26. 引發錯誤

函式可以透過引發(raising)一個例外來向其呼叫端發出問題的訊號。關鍵字是 raise

def square_root(x):
    if x < 0:
        raise ValueError("square_root expects a non-negative number")
    return x ** 0.5

呼叫 square_root(-1) 會停在 raise 那一行,跳出函式,並在呼叫端尋找相符的 except。如果沒有任何呼叫端攔截它,指令碼就會以一段追溯訊息(traceback)結束。

2.26.1. 為什麼要引發例外而不是回傳哨兵值

回報「不良輸入」有兩種方式:

# signal with a sentinel
def square_root_or_none(x):
    if x < 0:
        return None
    return x ** 0.5

# raise an exception
def square_root(x):
    if x < 0:
        raise ValueError("...")
    return x ** 0.5

例外的形式通常比較好:

  • 呼叫端必須刻意地處理錯誤情況 -- 要嘛用 try,要嘛讓例外向外傳播。哨兵值容易被忘記,也容易被誤認為正常結果。

  • 錯誤訊息會隨著例外一起傳遞;哨兵值的做法則必須把診斷資訊附加在其他地方。

  • 未處理例外的預設行為是一次明顯的崩潰,並附帶一段指向出問題呼叫的追溯訊息。靜默地回傳 None 之後會變成難以察覺的錯誤。

只有當「找不到」是一種例行且非例外性的結果時,才使用哨兵值 -- dict.get() 在鍵缺失時回傳 None,正是因為查詢本來就預期有時會落空。

2.26.2. 自訂例外類別

若要引發一個呼叫端可能想要與內建錯誤區分開來的問題,請定義一個 Exception 的子類別:

class ConfigError(Exception):
    pass

def load_config(path):
    try:
        f = open(path)
    except OSError as e:
        raise ConfigError("missing config file: " + path)

try:
    load_config("settings.json")
except ConfigError as e:
    print("startup failed:", e)

空的 class 主體沒有問題 -- 重要的是名稱本身,因為呼叫端是依類別來攔截的。如果呼叫端可能想在同一個區塊中攔截整個系列的錯誤,請把相關的錯誤歸在一個共同的基底之下。

2.26.2.1. 重新引發

except 區塊內使用裸 raise 會重新引發目前的例外,使其向上傳播到下一個處理器:

try:
    do_work()
except Exception as e:
    log(e)
    raise        # let it keep going

當函式想要觀察某個錯誤(記錄它、計數它、復原部分變更)而不實際處理它時,這就是正確的形式。

2.26.3. 何時攔截、何時傳播

一個實用的經驗法則:

  • 在能夠有意義地復原的層級攔截例外 -- 替換成預設值、重試、略過不良輸入。

  • 當除了崩潰之外無計可施,或當上層才是知道如何復原的那一層時,就讓它向外傳播。

位於呼叫堆疊中段、會吞掉所有錯誤並靜默回傳的函式,會讓失敗無從追溯。請優先讓例外一路傳遞,直到抵達真正有應對計畫的程式碼。