Python – 例外與錯誤處理
主題:自訂例外類別
簡介
在日常開發中,我們常會碰到各式各樣的錯誤:檔案不存在、使用者輸入不合法、網路連線逾時… 這些情況若不加以妥善處理,程式不僅會直接中斷,還可能留下不易追蹤的隱藏問題。Python 提供了強大的 例外 (Exception) 機制,讓開發者可以捕捉、處理甚至自行定義錯誤類型。
自訂例外類別的主要目的有兩點:
- 讓錯誤訊息更具語意:比起直接拋出
ValueError、RuntimeError,自訂例外能清楚說明是哪個業務邏輯失敗。 - 在多層呼叫堆疊中精準捕捉:只捕捉特定的錯誤,而不會不小心吞掉其他不相關的例外。
本篇文章將從概念說明、實作範例、常見陷阱到實務應用,完整介紹如何在 Python 中建立與使用自訂例外類別,適合 初學者到中級開發者 參考。
核心概念
1. 例外類別的繼承結構
Python 所有例外都是 BaseException 的子類別,而大多數自訂例外會直接繼承自 Exception,因為 Exception 代表「可被捕捉的錯誤」。以下圖示簡要說明:
BaseException
└─ Exception
├─ ValueError
├─ IOError
└─ MyCustomError ← 自訂例外
Tip:除非有特殊需求(例如要拋出系統層面的錯誤),否則不要直接繼承
BaseException。
2. 建立最簡單的自訂例外
只需要定義一個空的子類別即可:
class MyError(Exception):
"""當程式執行遇到特定條件時拋出的自訂例外。"""
pass
這樣的類別已經具備所有 Exception 的屬性與行為,使用方式與內建例外相同。
3. 加入自訂屬性與訊息
實務上,我們往往希望例外攜帶額外資訊(例如錯誤代碼、相關資料),以便在捕捉後進一步處理。
class ValidationError(Exception):
"""驗證失敗時拋出,包含錯誤代碼與失敗的欄位名稱。"""
def __init__(self, field: str, message: str, code: int = 400):
self.field = field # 失敗的欄位
self.code = code # HTTP 狀態碼或自訂代碼
# 呼叫父類別的建構子,讓 str(exc) 能顯示訊息
super().__init__(f"[{code}] {field}: {message}")
4. 例外的層級設計
在大型專案中,建議建立一個 例外基底類別,所有自訂例外都繼承自它,這樣可以一次捕捉整個模組的錯誤。
class MyAppError(Exception):
"""所有自訂例外的根基底類別。"""
pass
class DatabaseError(MyAppError):
"""資料庫相關的錯誤。"""
pass
class ServiceError(MyAppError):
"""外部服務呼叫失敗的錯誤。"""
pass
5. 何時拋出自訂例外?
- 業務規則違反:如使用者輸入不符合規格。
- 外部資源不可用:如 API 回傳非預期狀態。
- 程式內部狀態錯誤:如資料結構不一致。
程式碼範例
以下提供 5 個實用範例,每個範例皆附上說明與使用情境。
範例 1️⃣ 基礎自訂例外與捕捉
class NegativeNumberError(Exception):
"""當傳入負數時拋出的例外。"""
pass
def sqrt(x: float) -> float:
if x < 0:
raise NegativeNumberError("sqrt() 不接受負數")
return x ** 0.5
try:
result = sqrt(-9)
except NegativeNumberError as e:
print(f"捕捉到自訂例外: {e}")
說明:
raise關鍵字用來拋出例外,except區塊只捕捉我們自訂的NegativeNumberError,不會吞掉其他錯誤。
範例 2️⃣ 帶有自訂屬性的例外
class PermissionDeniedError(Exception):
"""存取受限資源時使用的例外,包含使用者 ID 與所需權限。"""
def __init__(self, user_id: str, required_role: str):
self.user_id = user_id
self.required_role = required_role
message = f"User '{user_id}' 缺少角色 '{required_role}'"
super().__init__(message)
def access_admin_panel(user):
if not user.get('is_admin'):
raise PermissionDeniedError(user['id'], 'admin')
print("歡迎進入管理介面!")
# 測試
user_info = {'id': 'u123', 'is_admin': False}
try:
access_admin_panel(user_info)
except PermissionDeniedError as err:
print(f"權限錯誤 → 使用者: {err.user_id}, 需求角色: {err.required_role}")
說明:自訂屬性
user_id、required_role讓捕捉端可以直接取得錯誤相關資訊,避免再次查詢資料庫。
範例 3️⃣ 例外層級設計與一次捕捉
class MyAppError(Exception):
"""所有自訂例外的根基底類別。"""
pass
class ConfigError(MyAppError):
"""設定檔錯誤。"""
pass
class NetworkError(MyAppError):
"""網路相關錯誤。"""
pass
def load_config(path):
if not path.endswith('.json'):
raise ConfigError("設定檔必須是 JSON 格式")
# 假裝讀檔失敗
raise ConfigError("找不到設定檔")
def fetch_data(url):
# 假裝連線逾時
raise NetworkError("連線逾時")
try:
load_config('settings.yaml')
fetch_data('https://api.example.com')
except MyAppError as e: # 一次捕捉所有自訂例外
print(f"應用層面的錯誤: {e}")
說明:只要捕捉
MyAppError,就能一次處理所有自訂例外,維持程式碼的簡潔與可維護性。
範例 4️⃣ 自訂例外與日誌記錄
import logging
logging.basicConfig(level=logging.ERROR, format='%(asctime)s %(levelname)s %(message)s')
class DataCorruptionError(Exception):
"""資料損毀時拋出的例外。"""
pass
def process_record(record):
if record.get('checksum') != record.get('calc_checksum'):
raise DataCorruptionError("檢查碼不符,資料可能已被竄改")
# 正常處理流程
return True
record = {'id': 1, 'checksum': 'abc', 'calc_checksum': 'def'}
try:
process_record(record)
except DataCorruptionError as exc:
logging.error(f"資料錯誤: {exc} (record_id={record['id']})")
# 依需求回傳錯誤代碼或重試
說明:在捕捉例外的同時寫入日誌,可協助日後排錯與監控。
範例 5️⃣ 結合 __str__/__repr__ 提供友善訊息
class RateLimitExceededError(Exception):
"""API 呼叫超過頻率限制的例外。"""
def __init__(self, limit: int, period: str):
self.limit = limit
self.period = period
super().__init__(f"已超過 {limit} 次/ {period} 的呼叫上限")
def __repr__(self):
return f"<RateLimitExceededError limit={self.limit} period='{self.period}'>"
def call_api():
# 假設已超過上限
raise RateLimitExceededError(100, "分鐘")
try:
call_api()
except RateLimitExceededError as e:
print(str(e)) # 使用者友善訊息
print(repr(e)) # 開發者除錯訊息
說明:
__str__(由Exception.__str__繼承)提供給使用者的訊息,__repr__則給開發者在除錯時使用。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 改善方式 |
|---|---|---|
過度捕捉 Exception |
用 except Exception: 捕捉所有錯誤,容易把程式內部的 bug 隱藏起來。 |
只捕捉特定的自訂例外或明確的內建例外。 |
自訂例外不繼承 Exception |
直接繼承 BaseException 會讓 KeyboardInterrupt、SystemExit 等系統例外被誤捕。 |
永遠繼承 Exception,除非有特殊需求。 |
| 在例外訊息中硬編碼 | 例外訊息寫死字串,無法動態提供上下文資訊。 | 使用 格式化字串 或在 __init__ 中傳入參數。 |
| 缺乏文件說明 | 自訂例外類別沒有 docstring,使用者不清楚何時拋出。 | 為每個例外 加上清楚的說明,並在 API 文件中列出。 |
| 例外層級過深 | 層級過多會讓捕捉變得複雜。 | 保持層級扁平,只在必要時建立子類別。 |
最佳實踐清單
- 建立根基底類別(如
MyAppError),方便一次捕捉整個模組的錯誤。 - 在
__init__中呼叫super().__init__(message),確保str(exc)正常工作。 - 提供額外屬性(代碼、欄位、使用者 ID 等),讓上層程式能根據這些資訊決策。
- 使用
logging記錄例外,不要僅以print輸出。 - 在單元測試中驗證例外類別,確保它們在正確條件下被拋出。
實際應用場景
1. Web API 的錯誤回傳
在 Flask/Django 等框架中,常會將自訂例外映射成 HTTP 狀態碼與 JSON 錯誤訊息:
# Flask 範例
from flask import Flask, jsonify
app = Flask(__name__)
class BadRequestError(MyAppError):
status_code = 400
def __init__(self, message):
super().__init__(message)
@app.errorhandler(BadRequestError)
def handle_bad_request(error):
response = jsonify({'error': str(error)})
response.status_code = error.status_code
return response
@app.route('/login', methods=['POST'])
def login():
data = request.get_json()
if not data.get('username'):
raise BadRequestError("缺少 'username' 欄位")
# 正常處理...
使用自訂例外後,錯誤處理與業務邏輯分離,程式碼更易維護。
2. 資料驗證套件
建立一套驗證函式庫時,將每種驗證失敗定義為不同的例外,讓使用者可以根據需求只捕捉特定錯誤:
class LengthError(ValidationError): pass
class FormatError(ValidationError): pass
3. 多執行緒或多進程的錯誤傳遞
在 concurrent.futures 中,子執行緒若拋出自訂例外,主執行緒仍能透過 future.exception() 取得:
from concurrent.futures import ThreadPoolExecutor
def worker(x):
if x < 0:
raise NegativeNumberError("負數不允許")
return x * 2
with ThreadPoolExecutor() as pool:
futures = [pool.submit(worker, i) for i in [-1, 2, 3]]
for f in futures:
exc = f.exception()
if exc:
print(f"子執行緒錯誤: {exc}")
總結
- 自訂例外 讓程式的錯誤資訊更具語意、易於追蹤,也能在多層呼叫堆疊中精準捕捉。
- 建議 繼承自
Exception,並在必要時建立 根基底類別 以統一管理。 - 為例外加入 自訂屬性、友善訊息,配合
logging、單元測試,可大幅提升系統的可維護性與除錯效率。 - 在 Web API、資料驗證、併發程式等實務情境中,自訂例外都是 分離關注點、提升可讀性 的關鍵工具。
掌握了自訂例外的設計與使用,你的 Python 程式將能在錯誤發生時保持優雅、可控,同時提供使用者與開發團隊清晰的錯誤回饋。祝你在開發旅程中寫出更穩健、更易維護的程式碼!