Pythonでは標準の例外クラスだけでなく、自分のアプリやライブラリに適した独自の例外を定義できます。
独自例外を使うと、原因の特定やハンドリングの粒度が上がり、コードの見通しが大きく改善します。
本記事では、Exceptionを継承して独自例外を定義する実践方法を、初心者にも分かりやすいサンプルとともに丁寧に解説します。
独自例外とは?Pythonの基本
例外(エラー)の役割とメリット
Pythonにおける例外は、通常の処理では対処できない事態を通知する仕組みです。
例外が発生すると、その場の処理は中断され、対応するexcept
節に制御が移ります。
もし捕捉されなければ、スタックトレース(Traceback)が表示されプログラムは終了します。
例外を使うメリットは、異常系を明確に切り出し、通常のロジックと分離できることです。
例外により、コードは読みやすく、保守しやすくなります。
また、例外の種類に応じて処理を分岐できるため、原因に合った対処を行いやすくなります。
独自例外を定義する理由
標準のValueError
やKeyError
などは便利ですが、アプリ固有のドメインには曖昧なことがあります。
例えば「注文の状態が不正」と「APIのレート制限に達した」は、どちらもRuntimeError
で表現できますが、同じ型だと区別しづらくなります。
そこで独自例外を用意して、意味のある名前で原因を明確化します。
標準例外では足りない場面
- 設定ファイルの必須キーが欠落している
- 外部APIの認証に失敗した
- ビジネスルール違反(例: 注文の状態遷移が無効)
- 一時的なリトライ可能エラーと恒久的なエラーを区別したい
こうした場面では、独自の例外型を導入すると、ハンドリングが簡潔になり、上位コードでの判定やログ出力も明快になります。
Pythonで独自例外を定義する(Exception継承)
最小の例(class MyError(Exception))
独自例外はException
を継承してシンプルに定義できます。
# 最小の独自例外の定義と、未捕捉時の挙動を確認する例
class MyError(Exception):
"""アプリ固有のエラーを表す最小の例外クラス"""
pass
def run():
# 何らかのエラー条件を検出したと仮定して、意図的に発生させる
raise MyError("何かがうまくいきませんでした")
run() # try-exceptで捕まえないので、Tracebackが表示される
出力例(Traceback):
Traceback (most recent call last):
File "example.py", line 12, in <module>
run()
File "example.py", line 9, in run
raise MyError("何かがうまくいきませんでした")
MyError: 何かがうまくいきませんでした
上記のように未捕捉だとプログラムは停止します。
実際のアプリでは、上位で適切に捕捉してログ出力や復旧処理を行います。
例外メッセージを持たせる(__init__)
メッセージや原因となった値など、追加情報を属性として保持すると、デバッグやログが楽になります。
# メッセージと関連情報を属性で持たせる例
# 文字列だけでなく、原因のキーやIDなどを属性で渡すと、機械可読なハンドリングが可能
class ConfigKeyMissingError(Exception):
"""設定ファイルの必須キーが欠落していることを表す例外"""
def __init__(self, key: str, *, filepath: str | None = None):
self.key = key
self.filepath = filepath
# 親クラスにメッセージを渡す。str(e) 時はこのメッセージが使われる
msg = f"必須キーが見つかりません: key={key}"
if filepath:
msg += f" (file={filepath})"
super().__init__(msg)
def load_config():
# ダミー: 必須キー "token" がないと仮定
raise ConfigKeyMissingError("token", filepath="settings.toml")
try:
load_config()
except ConfigKeyMissingError as e:
# 例外オブジェクトから属性を取り出して用途別に処理できる
print("短いメッセージ:", str(e))
print("欠落キー:", e.key)
print("ファイル:", e.filepath)
短いメッセージ: 必須キーが見つかりません: key=token (file=settings.toml)
欠落キー: token
ファイル: settings.toml
メッセージは人間向け、属性はプログラム向けという分担にすると、ログと分岐の両方で使いやすくなります。
パッケージ用の基底例外(BaseError)を用意
ライブラリや大きなアプリでは、自分の名前空間用の基底例外を1つ用意すると、その配下の全エラーを一括捕捉できます。
# ライブラリ "acme" を想定した基底例外とサブクラス群の例
class AcmeError(Exception):
"""acmeライブラリ全般の基底例外"""
pass
class AcmeConfigError(AcmeError):
"""設定関連のエラー"""
pass
class AcmeAPIError(AcmeError):
"""外部API呼び出し関連のエラー"""
def __init__(self, status: int, message: str = "API error"):
self.status = status
super().__init__(f"{message} (status={status})")
def call_api():
# ダミー: 503エラーが返ってきた
raise AcmeAPIError(503, "Service Unavailable")
try:
call_api()
except AcmeAPIError as e:
print("APIだけ個別にリトライ:", e)
except AcmeError as e:
# 他のacmeエラーはまとめて処理
print("acmeの一般的なエラー:", e)
APIだけ個別にリトライ: Service Unavailable (status=503)
このように、まず個別の例外を狭く捕まえ、最後に基底例外AcmeError
で広く受けると、段階的なハンドリングが可能になります。
命名規則と配置場所(例外クラスの置き場)
独自例外の命名と配置は、読み手の理解に直結します。
- 命名は「意味のある名詞+Error」を基本にし、一貫性を保ちます。
- ライブラリや大規模アプリでは
exceptions.py
など専用モジュールを用意します。 - パッケージの
__init__.py
で公開したい例外だけ再エクスポートすると使いやすくなります。
良い例/悪い例の比較:
目的 | 悪い例 | 良い例 |
---|---|---|
設定キー欠落 | Missing | ConfigKeyMissingError |
認証失敗 | Failed | AuthenticationError |
無効な状態 | BadState | InvalidStateError |
ドメイン固有 | RuntimeError | OrderStatusTransitionError |
プロジェクト構成の一例:
acme/
__init__.py # from .exceptions import AcmeError, AcmeAPIError, AcmeConfigError
exceptions.py # クラス定義を集約
api.py
config.py
この構成により、利用側はfrom acme import AcmeError
のようにシンプルに参照できます。
- 関連記事: 関数を別ファイル化する方法(モジュール作成)
使い方:raiseとtry-exceptで扱う
raiseで独自例外を発生させる
raise
で任意の場所から意図的に例外を送出します。
既存の例外をラップして原因を保つ場合はraise ... from ...
を使います。
# 例外の送出と、原因例外のチェイニング(raise from)の例
class ConfigParseError(Exception):
pass
def parse_token(d: dict) -> str:
try:
# dictからtokenを取り出すが、キーがなければKeyErrorが起きる
return d["token"]
except KeyError as e:
# 原因(KeyError)を保ったまま、より意味のある例外に変換
raise ConfigParseError("設定からtokenを読み取れませんでした") from e
# デモ
try:
parse_token({"user": "alice"}) # "token" がない
except ConfigParseError as e:
print("アプリ向けのわかりやすい例外:", e)
アプリ向けのわかりやすい例外: 設定からtokenを読み取れませんでした
raise fromを使うと元の原因がTracebackに残るため、根本原因の特定が容易になります。
try-exceptで特定の例外だけ捕捉
特定の例外だけを捕捉し、それ以外は上位に送ります。
except
の並び順は狭い型から広い型へ並べます。
class PaymentError(Exception):
pass
class InsufficientBalanceError(PaymentError):
pass
def charge(balance: int, amount: int):
if amount > balance:
raise InsufficientBalanceError("残高不足です")
print("決済に成功しました")
try:
charge(balance=100, amount=120)
except InsufficientBalanceError as e:
print("残高を確認して再実行してください:", e)
except PaymentError as e:
print("支払いに関する一般的なエラー:", e)
残高を確認して再実行してください: 残高不足です
必要に応じてelse
やfinally
で、成功時だけの処理や必ず行う後始末を追加できます。
継承関係で広く/狭く捕まえる
継承を活かすと、粒度の違うハンドリングを簡潔に書けます。
順序を誤ると、狭い例外に到達しない点に注意します。
class AppError(Exception): # 広い
pass
class DataError(AppError): # 狭い
pass
class NetworkError(AppError): # 狭い
pass
def do_task(kind: str):
if kind == "data":
raise DataError("データ形式が不正")
elif kind == "net":
raise NetworkError("タイムアウト")
else:
print("正常終了")
for k in ("data", "net", "ok"):
try:
do_task(k)
except DataError as e: # まず狭い型
print("[DATA] 再フォーマットを試行:", e)
except NetworkError as e: # 次に別の狭い型
print("[NET] リトライ待機:", e)
except AppError as e: # 最後に広い型
print("[APP] 一般処理:", e)
[DATA] 再フォーマットを試行: データ形式が不正
[NET] リトライ待機: タイムアウト
正常終了
ベストプラクティスと注意点
Exceptionを継承する(RuntimeErrorなどは避ける)
独自例外はExceptionを直接継承するのが基本です。
BaseExceptionの直接継承は絶対に避けてください(SystemExitやKeyboardInterruptまで捕まる設計は危険です)。
また、特別な理由がない限り、RuntimeErrorやValueErrorを安易に継承しない方が混乱しません。
意味づけを自前の型名で明確にしましょう。
悪い例:
# 意味があいまいで、外部から見るとRuntimeErrorと区別がつかない
class MyRuntimeError(RuntimeError):
pass
良い例:
class SpreadsheetImportError(Exception):
"""スプレッドシートの取り込みに失敗したことを表す"""
pass
メッセージは短く具体的に
メッセージは何が悪いのかを一言で特定できるようにし、詳細は属性へ。
ログに埋もれない短さを心がけます。
悪い | 良い |
---|---|
うまくいきませんでした | 顧客IDが無効です: id=abc |
エラーが発生しました | CSVの列数が不足しています: expected=5, got=3 |
保存できません | S3への書き込みに失敗しました(bucket=logs, key=2024-10.csv) |
属性で補助情報を渡す例:
class CustomerNotFoundError(Exception):
def __init__(self, customer_id: str):
self.customer_id = customer_id
super().__init__(f"顧客が見つかりません: id={customer_id}")
制御フローに乱用しない
例外は例外的な状況の通知に使い、通常フローの分岐には使いません。
例えば、キーの存在確認はin
で済むなら例外で分岐しない方が単純です。
ただしPython文化のEAFP(まず試して、ダメなら例外)が有効な場面もあります。
意図的に使い分けましょう。
# 良くない: 通常フローで例外を多用
def get_price_bad(d: dict) -> int:
try:
return d["price"]
except KeyError:
return 0 # 本当に0で良いのか判別しづらい
# 良い: 通常は明示的に分岐、足りない時だけ例外
def get_price_good(d: dict) -> int:
if "price" in d:
return d["price"]
raise KeyError("priceが存在しません")
パフォーマンス上、例外はコストが高い点にも注意します。
ホットパスでは通常分岐を優先しましょう。
公開APIは投げる例外を明記する
ライブラリや公共の関数は、発生しうる例外をドキュメント化します。
利用者はexcept
の書き方を事前に決められます。
class ReportGenerationError(Exception):
pass
def generate_report(path: str) -> str:
"""
指定パスからレポートを生成して、出力ファイルのパスを返します。
Raises:
FileNotFoundError: 入力ファイルが見つからない場合
PermissionError: 出力先に書き込めない場合
ReportGenerationError: レポート生成の内部エラー一般
"""
try:
# ダミー: 何かの内部処理
raise ValueError("内部の整合性エラー")
except ValueError as e:
# 利用者にとって意味のある例外に変換して公開
raise ReportGenerationError("レポート生成に失敗しました") from e
利用側は次のように書けます。
try:
generate_report("input.csv")
except FileNotFoundError:
print("入力ファイルを確認してください")
except ReportGenerationError as e:
print("生成処理の失敗:", e)
生成処理の失敗: レポート生成に失敗しました
まとめ
独自例外は、原因を表す名前と必要最小限で具体的なメッセージ、そして属性による機械可読な情報という3点を押さえると、実装も運用もスムーズになります。
まずはException
から継承した最小のクラスを定義し、プロジェクト単位で基底例外を用意しましょう。
その上で、狭い型から広い型への順にexcept
を書く、raise from
で原因を残す、公開APIで発生しうる例外を明記するといった原則を徹底すれば、エラー処理は格段に読みやすく強靭になります。
独自例外は、Pythonのエラー処理をあなたのドメインに最適化する最良の道具です。