Python の例外設計 — カスタム例外クラスの作り方
概要
Python でアプリケーションを書いていると、ValueError や RuntimeError といった組み込み例外をそのまま投げる場面が増えてきます。小さなスクリプトならそれで十分ですが、規模が大きくなると「この例外はどこから来たのか」「どう処理を分岐すればいいのか」が追いにくくなります。
この記事では、Python でカスタム例外クラスをどう設計するか、入門者向けに段階を追って整理します。
なぜカスタム例外が必要か
まず、組み込み例外だけで書いたコードを見てみます。
def withdraw(balance: int, amount: int) -> int:
if amount <= 0:
raise ValueError("金額が不正です")
if amount > balance:
raise ValueError("残高が不足しています")
return balance - amount
呼び出し側はこうなります。
try:
new_balance = withdraw(1000, 2000)
except ValueError as e:
# 「金額不正」なのか「残高不足」なのか、メッセージ文字列でしか判別できない
print(e)
問題は、両方とも ValueError なので 例外の種類で処理を分岐できない ことです。メッセージ文字列で if "残高" in str(e) のように判定するのは壊れやすく、避けるべきです。カスタム例外を使うと、種類ごとに except を分けられます。
基本: 例外は Exception を継承する
カスタム例外は Exception を継承するだけで作れます。
class WithdrawError(Exception):
"""出金処理に関するエラーの基底クラス"""
class InvalidAmountError(WithdrawError):
"""金額が不正なとき"""
class InsufficientBalanceError(WithdrawError):
"""残高が不足しているとき"""
ポイントは2つあります。
- アプリケーション共通の基底例外を1つ作る(ここでは
WithdrawError) - 具体的なエラーはその基底を継承する
こうすると、呼び出し側は「細かく分けて捕まえる」ことも「まとめて捕まえる」こともできます。
def withdraw(balance: int, amount: int) -> int:
if amount <= 0:
raise InvalidAmountError("金額は正の整数で指定してください")
if amount > balance:
raise InsufficientBalanceError("残高が不足しています")
return balance - amount
try:
new_balance = withdraw(1000, 2000)
except InsufficientBalanceError:
print("残高を確認してください")
except InvalidAmountError:
print("入力金額を見直してください")
except WithdrawError:
# 上記以外の出金エラーをまとめて受け止める保険
print("出金処理でエラーが発生しました")
except WithdrawError が最後にあることで、将来エラーの種類が増えても取りこぼしを防げます。
階層設計の考え方
例外の継承関係は「捕まえる側がどう分岐したいか」を基準に設計します。
Exception
└── AppError (アプリ全体の基底)
├── WithdrawError (出金ドメインの基底)
│ ├── InvalidAmountError
│ └── InsufficientBalanceError
└── UserNotFoundError
- 最上位に
AppErrorを置くと、フレームワークのエラーハンドラで「自前の例外か、想定外の例外か」を一括判定できます - ドメインごとに中間の基底(
WithdrawError)を挟むと、まとめ捕まえの粒度を選べます
階層を深くしすぎると管理が面倒になるので、最初は2〜3段で十分です。
例外に追加情報を持たせる
例外はただのメッセージ運搬役ではありません。__init__ を上書きして、処理に使える情報を属性として持たせられます。
class InsufficientBalanceError(WithdrawError):
def __init__(self, balance: int, amount: int) -> None:
self.balance = balance
self.amount = amount
self.shortfall = amount - balance
super().__init__(
f"残高不足です(残高={balance}, 要求={amount}, 不足={self.shortfall})"
)
呼び出し側で構造化された値を使えます。
try:
withdraw(1000, 2000)
except InsufficientBalanceError as e:
print(f"あと {e.shortfall} 円不足しています")
ここで重要なのは、super().__init__(...) を 必ず呼ぶ ことです。これを忘れると str(e) が空になり、ログに何も残らないという事故が起きます。
例外の連鎖(raise ... from)
低レベルの例外を捕まえて、アプリ用の例外に変換する場面はよくあります。このとき from を使うと、元の例外を失わずに連鎖させられます。
def load_config(path: str) -> dict:
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError as e:
raise ConfigError(f"設定ファイルが見つかりません: {path}") from e
from e を付けると、トレースバックに元の原因(FileNotFoundError)が残るので、デバッグが格段に楽になります。逆に、連鎖を意図的に断ち切りたいときは from None を使います。
確認方法
テストで例外を検証する場合は pytest.raises を使います。
import pytest
def test_insufficient_balance():
with pytest.raises(InsufficientBalanceError) as exc_info:
withdraw(1000, 2000)
assert exc_info.value.shortfall == 1000
def test_invalid_amount():
with pytest.raises(InvalidAmountError):
withdraw(1000, 0)
注意点
- 組み込み例外を使うべき場面もある。引数の型が完全に間違っているなら素直に
TypeError、未実装ならNotImplementedErrorを使う方が自然です。何でもカスタム例外にする必要はありません。 - 例外クラスに重いロジックを入れない。例外はあくまで「何が起きたか」を伝える入れ物です。DB アクセスや外部通信を
__init__でやらないこと。 - 基底例外の捕まえすぎに注意。
except AppErrorは便利ですが、むやみに広く捕まえると本来落とすべきバグを握りつぶします。 - メッセージに機密情報を入れない。例外メッセージはログに残るため、パスワードやトークンを含めないよう注意します。
まとめ
- アプリ共通の基底例外を1つ作り、そこから継承させると捕まえ方の粒度を選べる
__init__を上書きして属性を持たせると、呼び出し側が構造化された情報を使える(super().__init__()を忘れない)- 低レベル例外を変換するときは
raise ... from eで原因を残す - 何でもカスタム例外にせず、組み込み例外が適切な場面は活かす
例外設計は「投げる側」ではなく「捕まえる側がどう分岐したいか」から考えると、自然な階層が見えてきます。
