Python – 例外與錯誤處理(Exception Handling)
主題:assert 斷言
簡介
在開發 Python 程式時,我們常常需要驗證「程式在某個階段的假設是否成立」。如果假設不成立,最直接的做法就是拋出例外,讓開發者或測試人員立即發現問題。assert(斷言)正是一個為此而設計的語法,能在程式執行過程中快速檢查條件,並在條件為 False 時拋出 AssertionError。
斷言的好處在於:
- 即時偵錯:在開發或測試階段,斷言可以立即指出程式邏輯的錯誤,避免錯誤在更深層的程式碼中悄悄傳播。
- 文件化假設:
assert本身就是對程式「前置條件」或「不變條件」的說明,讓程式碼自帶說明文件。 - 低成本:相較於手動拋出例外或寫大量的
if … raise,斷言語法簡潔且可在執行時自動關閉(使用-O或-OO參數),不會影響正式環境的效能。
然而,斷言不是萬能的錯誤處理工具。掌握它的適用範圍與限制,才能在實務上發揮最大效益。接下來,我們會從概念、範例、常見陷阱與最佳實踐,逐步深入說明 assert 的使用方式。
核心概念
1. assert 的語法與基本行為
assert <條件表達式>, <錯誤訊息 (可選)>
- 條件表達式:任意返回布林值的 Python 表達式。
- 錯誤訊息:可選的字串,用於說明斷言失敗時的原因。若未提供,預設訊息為空字串。
當條件表達式的結果為 True,assert 什麼也不做,程式繼續往下執行。
當結果為 False,Python 會拋出 AssertionError,並顯示提供的錯誤訊息(若有)。
注意:在執行
python -O(optimize)或python -OO時,所有的assert會被自動移除,等同於寫成pass。因此斷言不應用於必須在正式環境執行的檢查。
2. 斷言的典型用途
| 用途 | 說明 | 範例 |
|---|---|---|
| 檢查函式參數 | 確認傳入的參數符合預期的型別或範圍 | assert isinstance(x, int) and x > 0, "x 必須是正整數" |
| 驗證不變條件 | 在迴圈或演算法的關鍵點,保證某些值不會被破壞 | assert total == sum(items), "總和不一致" |
| 測試內部狀態 | 在開發階段快速檢查物件的內部屬性 | assert obj.state == "ready" |
| 文件化假設 | 讓程式碼自說明,其他開發者一眼即可了解前提 | assert len(data) == expected_len, "資料長度錯誤" |
3. 斷言與例外的差異
| 項目 | assert |
raise/自訂例外 |
|---|---|---|
| 目的 | 檢查 開發者假設,不應在正式環境依賴 | 處理 預期的錯誤情境(使用者輸入、外部資源失敗) |
| 效能 | 在 -O 模式下會被移除,無執行成本 |
永遠會被執行,除非額外條件判斷 |
| 錯誤類型 | 預設為 AssertionError,可自訂訊息 |
可自訂例外類別,提供更細緻的錯誤資訊 |
| 可捕捉性 | 可以 except AssertionError 捕捉,但不建議在正常流程中使用 |
常作為業務流程的一部份,被明確捕捉與處理 |
4. 斷言的最佳寫法
- 保持簡潔:斷言表達式應該是一行,避免過於複雜的計算。
- 提供有意義的訊息:錯誤訊息最好包含失敗的變數值,方便除錯。
- 僅在開發/測試階段使用:不要把必須驗證的商業邏輯寫成斷言。
- 避免副作用:斷言內的表達式不應該改變程式狀態(例如呼叫函式改變全域變數),因為在
-O模式下會被跳過。
程式碼範例
以下示範 5 個實務常見的 assert 用法,並附上詳細註解說明。
範例 1:檢查函式參數的型別與範圍
def calculate_discount(price: float, discount: float) -> float:
"""
計算折扣後的價格。
- price 必須是正數
- discount 必須在 0~1 之間(代表 0%~100%)
"""
# 斷言保證傳入的參數符合預期
assert price > 0, f"price 必須大於 0,實際收到 {price}"
assert 0 <= discount <= 1, f"discount 必須在 0~1 之間,實際收到 {discount}"
return price * (1 - discount)
# 正常呼叫
print(calculate_discount(1200, 0.15)) # 1020.0
# 若傳入錯誤的參數,會拋出 AssertionError
# calculate_discount(-100, 0.2) # AssertionError: price 必須大於 0,實際收到 -100
說明:這裡的斷言確保了商業邏輯的前置條件。若在正式環境仍需要檢查,應改用
if … raise ValueError。
範例 2:驗證演算法的不變條件
def insertion_sort(arr):
"""簡易的插入排序,演算法中使用斷言保證不變條件"""
for i in range(1, len(arr)):
key = arr[i]
j = i - 1
# 在每一次迭代開始前,左側已排序
assert arr[:i] == sorted(arr[:i]), f"左側子陣列未排序: {arr[:i]}"
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
# 迭代結束後,左側子陣列仍保持排序
assert arr[:i + 1] == sorted(arr[:i + 1]), f"插入後子陣列未排序: {arr[:i+1]}"
return arr
print(insertion_sort([5, 2, 9, 1]))
說明:在演算法開發階段,斷言可以快速捕捉邏輯錯誤;若演算法已穩定,正式發佈時可關閉斷言以提升效能。
範例 3:檢查資料結構的完整性
class Stack:
"""簡易的堆疊實作,使用斷言保證內部狀態一致"""
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
# 斷言保證堆疊大小與實際列表長度相同
assert len(self._items) == self.size(), "堆疊大小不一致"
def pop(self):
assert not self.is_empty(), "嘗試從空堆疊 pop"
item = self._items.pop()
assert len(self._items) == self.size(), "堆疊大小不一致"
return item
def size(self):
return len(self._items)
def is_empty(self):
return self.size() == 0
s = Stack()
s.push(10)
s.push(20)
print(s.pop()) # 20
說明:斷言用於驗證「內部不變條件」——如堆疊的大小與實際列表長度永遠相符。這類檢查在開發階段非常有幫助。
範例 4:測試函式的預期輸出
def factorial(n: int) -> int:
"""遞迴計算階乘,使用斷言檢查遞迴基礎案例"""
assert n >= 0, f"階乘只接受非負整數,收到 {n}"
if n == 0:
return 1
return n * factorial(n - 1)
# 測試案例
assert factorial(5) == 120, "5! 應該等於 120"
assert factorial(0) == 1, "0! 必須等於 1"
# assert factorial(-3) # 會拋出 AssertionError
說明:在單元測試中,
assert也是最直接的驗證方式。這裡同時示範了函式內部與測試外部的斷言。
範例 5:防止副作用的斷言(避免在 -O 時失效)
def get_config(key):
"""從全域設定字典取得值,若 key 不存在拋出例外"""
# 正確做法:使用 if 判斷,避免斷言在 -O 時被移除
if key not in CONFIG:
raise KeyError(f"設定項目 '{key}' 不存在")
return CONFIG[key]
# 錯誤示範(不建議):
def get_config_bad(key):
"""使用 assert 來檢查 key 是否存在(不安全)"""
assert key in CONFIG, f"設定項目 '{key}' 不存在"
return CONFIG[key] # 在 -O 模式下,assert 被移除,會直接拋出 KeyError
說明:此例說明 斷言不應該用於必須保證的商業邏輯,因為在優化模式下會被移除,導致錯誤行為變得難以偵測。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 解決方案 |
|---|---|---|
| 在正式環境依賴斷言檢查 | -O 模式下斷言被移除,錯誤不會被捕捉,導致程式在不符合前置條件時繼續執行,可能產生不可預期的結果。 |
對於必須保證的檢查,改用 if … raise,或自行建立自訂例外類別。 |
| 斷言內含副作用(如呼叫會改變全域變數的函式) | 在 -O 模式下副作用不會執行,程式行為改變,難以追蹤。 |
斷言表達式只能是純粹的「檢查」;所有副作用應放在斷言外。 |
| 訊息過於簡略 | 當斷言失敗時,只得到「AssertionError」而無法快速定位問題。 | 提供具體且包含變數值的訊息,如 f"x 必須大於 0,實際為 {x}"。 |
| 過度使用斷言 | 使程式碼充斥大量斷言,降低可讀性,且在大型專案中難以維護。 | 只在關鍵的「假設」或「不變條件」上使用;其餘情況使用單元測試或型別檢查工具(如 mypy)。 |
忽略 assert 的執行成本 |
雖然斷言在 -O 時被移除,但在開發階段仍會有執行成本,特別是大量迴圈內的斷言。 |
把成本較高的斷言移到迴圈外或僅保留必要的檢查。 |
最佳實踐清單
- 只在開發/測試階段使用:將斷言視為「開發者的安全網」,不要把它當成正式的錯誤處理機制。
- 提供清晰訊息:使用 f-string 包含失敗時的變數值,讓除錯更快速。
- 避免副作用:斷言的表達式應該是純粹的布林運算,絕不可呼叫會改變狀態的函式。
- 結合型別註解與靜態檢查:
assert isinstance(x, int)可補足mypy無法捕捉的 runtime 型別問題。 - 在 CI/CD 中保留斷言:除非有特別需求,建議在持續整合測試時保留斷言,以確保所有假設仍然成立。
實際應用場景
| 場景 | 為何使用 assert |
範例 |
|---|---|---|
| 資料前處理 | 確認 CSV 檔案的欄位數與預期相同,防止後續的 DataFrame 操作出錯。 | assert len(row) == EXPECTED_COLS, f"第 {i} 行欄位數錯誤" |
| 機器學習模型 | 檢查特徵向量的維度與模型訓練時相同,避免 ValueError。 |
assert X.shape[1] == model.n_features_, "特徵維度不匹配" |
| API 開發 | 驗證傳入的 JSON 結構符合規範,開發階段快速捕捉錯誤。 | assert isinstance(payload.get('id'), int), "id 必須是整數" |
| 多執行緒/多程序 | 確保共享資源的狀態在進入臨界區前符合預期。 | assert lock.locked() is False, "鎖已被其他執行緒持有" |
| 硬體驅動或嵌入式 | 在開發板上測試感測器回傳值範圍,避免因硬體異常導致系統崩潰。 | assert 0 <= temperature <= 100, f"溫度超出範圍: {temperature}" |
總結
assert 是 Python 中一個 簡潔而有力 的工具,適合在 開發階段 針對「程式假設」進行快速檢查。透過斷言,我們可以:
- 即時捕捉錯誤,減少後續除錯成本。
- 文件化程式的前置條件,提升程式可讀性與維護性。
- 在測試環境中自動驗證不變條件,確保演算法或資料結構的正確性。
然而,斷言 不是正式的錯誤處理機制,在正式上線時不應依賴它來保護關鍵邏輯。遵循以下原則,即可在實務專案中安全且有效地運用 assert:
- 僅在開發/測試環境使用。
- 提供具體且包含變數值的錯誤訊息。
- 斷言表達式保持純粹,避免副作用。
- 必要的商業邏輯檢查改用
if … raise。 - 結合型別註解、單元測試與 CI 流程,讓程式品質更上一層樓。
掌握了這些核心概念與實務技巧後,你就能在 Python 專案 中靈活運用 assert,讓程式更可靠、更易於維護。祝你寫程式快、除錯少、專案順利! 🚀