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. アプリケーション共通の基底例外を1つ作る(ここでは WithdrawError)
  2. 具体的なエラーはその基底を継承する

こうすると、呼び出し側は「細かく分けて捕まえる」ことも「まとめて捕まえる」こともできます。

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 で原因を残す
  • 何でもカスタム例外にせず、組み込み例外が適切な場面は活かす

例外設計は「投げる側」ではなく「捕まえる側がどう分岐したいか」から考えると、自然な階層が見えてきます。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。