跳到主要內容
程式語言

Python 的 EAFP 與 LBYL:例外處理為什麼貴,又該在哪裡用?

從 Stack Frame、Call Stack、Stack Unwinding、Traceback 與 Heap 拆解 Python 例外的成本,理解 EAFP 與 LBYL 的真正取捨。

你想從 dict 取一個可能不存在的 key。該先問它在不在,還是直接取了再處理 KeyError

# LBYL:Look Before You Leap
if "theme" in settings:
    theme = settings["theme"]
else:
    theme = "light"

# EAFP:Easier to Ask Forgiveness than Permission
try:
    theme = settings["theme"]
except KeyError:
    theme = "light"

Python 社群常把 EAFP 講成慣例,然後有人把它理解成「先做再說,反正出錯就 catch」。這就走偏了。

EAFP 的前提是:失敗是少數,而且你能精確處理那一種失敗。例外路徑確實比普通分支重得多;但為了怕例外而把每個操作都預先檢查,也會寫出重複、甚至有競態條件的程式。


EAFP 和 LBYL 到底差在哪?

EAFP(Easier to Ask Forgiveness than Permission,先做、失敗再補救) 直接執行真正想做的操作,失敗時捕捉預期的例外。

LBYL(Look Before You Leap,先檢查、再執行) 則先確認條件成立,再做操作。

兩者的差異不只是一個 if 和一個 try。它們對「檢查結果到真正操作之間,世界會不會改變」有不同假設。

from pathlib import Path

path = Path("report.txt")

# LBYL:exists() 與 open() 是兩個動作
if path.exists():
    with path.open(encoding="utf-8") as file:
        content = file.read()

這段看起來很謹慎,但在 exists() 回傳 True 後,另一個程序可能立刻刪掉檔案。open() 還是會失敗。

from pathlib import Path

try:
    with Path("report.txt").open(encoding="utf-8") as file:
        content = file.read()
except FileNotFoundError:
    content = ""

open() 才是你真正需要相信的原子操作。EAFP 在這裡不是比較瀟灑,而是把判斷交給檔案系統,也避開了 TOCTOU(Time Of Check to Time Of Use,檢查與使用之間的競態)


為什麼真的丟出例外比較貴?

先釐清一件事:try 區塊本身通常不是問題。正常路徑沒有丟出例外時,Python 不需要走完整的錯誤處理流程。真正昂貴的是 raise 之後的工作。

Stack Frame:每一層呼叫留下的執行現場

函式執行時,直譯器得保存這次呼叫的狀態。這份狀態叫做 Stack Frame(堆疊框架),裡面包含目前執行到哪一行、區域變數、函式的參數,以及回到呼叫者後要繼續的位置。

def divide(total, count):
    return total / count

def average(values):
    return divide(sum(values), len(values))

average([])

average() 呼叫 divide() 時,執行中的 frame 可以粗略想成這樣:

average() 呼叫 divide() 時的 Call Stack:最上層的 divide(total=0, count=0) 正在執行,下方依序是 average(values=[]) 與 module

這串由下往上互相呼叫的 frame,就是 Call Stack(呼叫堆疊)。平常函式 return,最上層 frame 正常結束,控制權回到下一層。這條路很短。

Stack Unwinding:例外要一路往回找人接

除以零時,divide() 沒有辦法給出結果,會丟出 ZeroDivisionError

def divide(total, count):
    return total / count

def average(values):
    return divide(sum(values), len(values))

try:
    average([])
except ZeroDivisionError:
    print("沒有資料,無法計算平均值")

直譯器不能直接跳到 except。它得先從 divide() 的 frame 找有沒有能處理 ZeroDivisionError 的 handler;找不到,就結束這層、回到 average(),再找一次;直到外層的 try 找到對應的 except

這個回退過程是 Stack Unwinding(堆疊展開)。呼叫層數越深,沒有 handler 的 frame 越多,例外路徑要處理的狀態也越多。

一般 return 和 exception 的 Call Stack:return 只回到呼叫者;exception 則從 divide() 往外層展開,直到 module 的 except 接住

Traceback:錯誤訊息不是憑空出現的

若沒有人捕捉這個例外,Python 會印出 Traceback(追蹤回溯):從最外層到出錯行的一串呼叫位置。

Traceback (most recent call last):
  File "app.py", line 8, in <module>
    average([])
  File "app.py", line 5, in average
    return divide(sum(values), len(values))
  File "app.py", line 2, in divide
    return total / count
           ~~~~~~^~~~~~~
ZeroDivisionError: division by zero

這些資訊對除錯很有價值,但例外物件必須帶著它們,才能告訴你錯在哪條呼叫鏈。實作細節會隨 Python 版本與直譯器而變;以 CPython 來說,例外處理會建立或串接例外與 traceback 狀態,並在需要時把執行 frame 的資訊暴露給你。它不是普通 if 分支那種「選另一條指令繼續跑」而已。

Heap:Traceback 可能把大物件留得更久

Heap(堆積) 是 Python 放置 list、dict、instance 等物件的記憶體區域。frame 裡的區域變數通常指向 heap 上的物件。

def import_file(path):
    rows = load_everything_into_memory(path)  # 可能是一大份 list
    raise ValueError("檔案格式不正確")

當你把例外存起來、傳到別處,或長時間掛在 log/監控物件上,traceback 可能仍參照著 import_file() 的 frame;那個 frame 又參照 rows。於是本來應該能回收的大型資料,會跟著 traceback 活久一點。

這不是說「例外一定造成記憶體洩漏」。例外不再被參照後,Python 可以回收相關物件。問題在於長期保存 traceback,會無意間延長整條物件圖的生命週期。批次處理或常駐服務裡,這種情況很值得留意。

例外的成本不是只有建一個 Exception。它還涉及非線性的控制流程、呼叫 frame 的回退,以及足以重建錯誤現場的 traceback 資訊。


哪些地方適合 EAFP?

判斷準則很簡單:操作本身才是可靠的判斷,而且失敗不常發生。

存取 mapping 中可選的 key

當 key 多半存在,而缺少 key 就是你要處理的狀況時,EAFP 很直接。

def get_timeout(config):
    try:
        return config["timeout"]
    except KeyError:
        return 30  # 沒設定時使用預設值

不過如果只有「取預設值」這個需求,dict.get() 更貼近意圖,也不必刻意在這裡展示 EAFP。

timeout = config.get("timeout", 30)

檔案、網路與資料庫等外部資源

資源狀態能在兩個 CPU 指令之間改變。你無法靠事前檢查保證後續操作成功。

try:
    connection = pool.get_connection()
    connection.send(payload)
except ConnectionError as error:
    logger.warning("傳送失敗:%s", error)

這裡仍得想清楚補救策略:能不能重試?重試是否會重複寫入?捕捉 ConnectionError 不等於錯誤已經被處理。

解析或轉型,但失敗確實罕見

def parse_port(value: str) -> int:
    try:
        return int(value)
    except ValueError as error:
        raise ValueError(f"port 必須是整數,收到 {value!r}") from error

int() 已經做了完整格式判斷。先用 value.isdigit() 反而會漏掉正負號、空白等規則,最後仍得呼叫 int()。直接操作,並保留原始例外作為原因,通常更可靠。


哪些地方該用 LBYL?

如果失敗是正常且頻繁的輸入,或你需要在操作前給使用者更具體的回饋,先驗證條件往往更合理。

def create_user(name: str, age: int) -> None:
    if not name.strip():
        raise ValueError("姓名不能是空白")
    if not 0 <= age <= 150:
        raise ValueError("年齡必須介於 0 到 150")

    save_user(name, age)

這裡的 LBYL 不是為了躲例外成本。你仍會用 ValueError 回報不合法輸入;差別是你在邊界把規則寫清楚,而不是拿 int() 或資料庫錯誤訊息當驗證器。

另一種情況是高頻熱路徑。假設 30% 的資料本來就不是數字,靠大量 ValueError 來篩選會讓例外路徑變成日常流程。這時可以先做便宜、符合需求的檢查,或調整資料格式,別把 exception 當成迴圈控制工具。


try 要包多小?

這是 EAFP 最容易踩到的坑。

# 太大:KeyError 到底來自設定、payload,還是 format_message?
try:
    timeout = config["timeout"]
    user_id = payload["user_id"]
    message = format_message(payload)
    send(message, timeout)
except KeyError:
    return {"error": "缺少必要欄位"}

這段會把 format_message() 裡意外出現的 KeyError 也吞掉,然後回傳一個看似合理、實際上誤導人的 API 錯誤。

try:
    user_id = payload["user_id"]
except KeyError:
    return {"error": "缺少 user_id"}

message = format_message(payload)
send(message, config.get("timeout", 30))

try 應該只包住你預期會失敗、而且知道怎麼補救的那個操作。例外類型也越精確越好:捕捉 FileNotFoundError,不要隨手寫 except Exception


結語

EAFP 不是 Python 版的「先衝再說」,LBYL 也不是比較保守就一定正確。你得先問:這個檢查能保證接下來的操作嗎?失敗究竟罕不罕見?我接到失敗後真的知道怎麼處理嗎?

外部資源和內建操作的結果,通常只有真正執行時才算數,EAFP 很合適。輸入驗證、常見的無效資料與高頻失敗,則該把規則攤開來做。下次看到 try/except,別只看它有沒有「優雅」;看它是不是把昂貴但資訊完整的錯誤路徑,留給真正例外的那一刻。