你想從 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 可以粗略想成這樣:
這串由下往上互相呼叫的 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 越多,例外路徑要處理的狀態也越多。
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,別只看它有沒有「優雅」;看它是不是把昂貴但資訊完整的錯誤路徑,留給真正例外的那一刻。