見出し画像

Python 3: Deep Dive (Part 4 - OOP): カスタム例外 (セクション12-3/15)

  • 例外を「発生」し、別の例外へ変換したりトレースバックを隠す手法(`raise ... from ...`)を使うことで、適切なエラー情報だけをユーザに提示可能。

  • カスタム例外と階層化により、ドメイン固有のエラー管理やログ・ユーザ向けメッセージの一元化を実現。

  • 多重継承を使うと、同一の例外を複数の例外型(例:`ValueError` とカスタム例外)の両方として扱うなど、柔軟な例外ハンドリングが可能。

これまで、例外処理 (`try`, `except`, `else`, `finally`) や呼び出しスタックの伝播モデルについて学んできました。この最終パートでは、コード内で直接例外を「発生 (raise)」する方法、それをなぜ別の例外へ変換したり、完全にマスクしたりする場合があるのかを掘り下げます。また、「カスタム例外」を作成して、Python の標準例外システムとうまく連携する方法にも触れます。


例外を手動で発生させる

基本

Python では `raise` 文を使って、意図的に例外ワークフローを開始できます。たとえば:

raise ValueError("Something bad happened")

内部的には、`raise` で発生させるオブジェクトが `BaseException` のサブクラス(直接あるいは間接的)である必要があります。もし `BaseException` を継承していないクラスやオブジェクトを `raise` しようとすると、「例外は BaseException から派生していなければならない」という `TypeError` が出ます。

引数を渡す

Python の組み込み例外のほとんどは、柔軟な複数引数のコンストラクタをサポートしています。内部的にはこれらの引数は例外オブジェクトの `args` 属性に格納され、文字列表現や `repr` 表現にも現れます:

ex = ValueError('Message', 42, 'Extra info')
print(ex.args)   # ('Message', 42, 'Extra info')
print(str(ex))   # "('Message', 42, 'Extra info')"
print(repr(ex))  # "ValueError('Message', 42, 'Extra info')"

通常は単一の引数でエラーメッセージを渡す場合が多いですが、追加の文脈情報を与えたいときなど、複数引数も役立ちます。


例外の再送出 (Re-Raising)

実際のコードでは、ログを取るなどの目的で例外を一旦キャッチした後、再び上位へ伝播させたい場合があります。その場合、`except` ブロックの中で引数なしの `raise` を使います:

def div(a, b):
    try:
        return a // b
    except ZeroDivisionError as ex:
        print("Logging ZeroDivisionError:", ex)
        raise

ここで新しい例外を指定しない `raise` は、もとの例外をそのまま伝播します。「ログなどの処理は行いたいが、最終的なハンドリングは別の箇所に任せたい」という場合に非常に便利です。


例外のチェーン (Chaining)

ある例外を別の例外に「変換」したいこともあります。これは、とくにカスタム例外を作って外部コードにドメイン固有のエラーだけを見せたいときによく行われます。Python では `except` ブロック内で新しい例外を発生させることで実現できます:

class CustomError(Exception):
    """A custom domain-specific error."""

def do_something(a, b):
    try:
        return a // b
    except ZeroDivisionError as ex:
        print("Converting ZeroDivisionError to CustomError...")
        raise CustomError(*ex.args)

トレースバックとネストされた例外

同じ例外を再送出する場合も、新しい例外に差し替える場合も、Python のトレースバックはデバッグに便利なようにすべての中間例外を表示します。たとえば:

try:
    raise ValueError("level 1")
except ValueError:
    try:
        raise TypeError("level 2")
    except TypeError:
        raise KeyError("level 3")

最終的には `KeyError` が表面化しますが、トレースバックには `ValueError`, `TypeError`, `KeyError` のすべての痕跡が含まれます。これはデバッグに有用ですが、ユーザには情報過多かもしれません。


トレースバックをマスクする: `raise ... from ...`

中間的な例外をユーザに見せたくない場合、Python にはトレースバックの一部を「マスク」する仕組みがあります。具体的には:

  • `raise SomeError(...) from None`
    これまでの例外を完全に隠し、新しいエラーだけを表示します。

  • `raise NewError(...) from original_exc`
    `original_exc` が新しい例外の直接の原因であると明示し、それ以外の例外を省略できます。

簡単な例として、文字列を `int` に変換しようとしたあと、フォールバックを試みる関数が最終的に失敗する場合を考えます:

class ConversionError(Exception):
    """Custom conversion error."""

def make_bool(val):
    try:
        if isinstance(val, int) and val in (0, 1):
            return bool(val)
        elif isinstance(val, str) and val.casefold() in {'0', 'false'}:
            return False
        elif isinstance(val, str) and val.casefold() in {'1', 'true'}:
            return True
        else:
            raise ValueError("Unsupported value")
    except ValueError as ex:
        raise ConversionError(f"Could not convert {val!r} to bool") from None

`from None` を使うと、ユーザは最終的な `ConversionError` だけを見て、その下にある `ValueError` の詳細は見ずに済みます。


カスタム例外を作る

なぜ & どのように継承するか

Python では、ほとんどのカスタム例外は組み込みの `Exception` を継承します(`BaseException` を直接継承するのはシステム系の特殊なケースを除き勧められません)。こうすることで、`SystemExit` や `KeyboardInterrupt` といったシステムレベルの例外とは区別しながら、他の実行時エラーと同じ階層に置くことができます。

class MyBaseError(Exception):
    """Application-specific root error."""

`ValueError` や `LookupError`, `TypeError` など、より特定の組み込み例外を継承することも可能です。たとえば:

class ReadOnlyError(AttributeError):
    """Raised when attempting to write to a read-only attribute."""

階層を作る

現実のライブラリやアプリケーションでは、複数段階の階層が必要になるかもしれません。例:

class WebScraperException(Exception):
    """Base exception for all web-scraper errors."""

class HTTPException(WebScraperException):
    """General HTTP exception in the scraper."""

class InvalidUrlException(HTTPException):
    """Raised when the URL is invalid."""

class TimeoutException(HTTPException):
    """Raised on HTTP timeouts."""
    pass

class PingTimeoutException(TimeoutException):
    """Specialized ping timeout."""

これにより、ライブラリの利用者は、状況に応じて (1) `InvalidUrlException` (2) `HTTPException` (3) `WebScraperException` といった異なるレベルで例外をキャッチできます。

機能を拡張する

例外はただのクラスなので、プロパティやメソッドを追加できます。たとえば REST API の場合、例外に `to_json()` を実装して JSON 形式のレスポンスを返すようにしたり、発生時に自動的にログを取るようにしたり、さまざまなカスタマイズが可能です。典型的には、共通の基底クラスを作り、以下のような機能をまとめておきます。

  1. HTTP ステータスコード(REST API 用など)

  2. 内部用メッセージとユーザ向けメッセージ

  3. ログ出力やシリアライズ のためのメソッド

たとえば:

import json
from http import HTTPStatus
from datetime import datetime

class APIException(Exception):
    """Base REST API exception with consistent logging and JSON output."""

    http_status = HTTPStatus.INTERNAL_SERVER_ERROR
    internal_err_msg = "Generic server-side error."
    user_err_msg = "We are sorry; an unexpected error occurred."

    def __init__(self, *args, user_err_msg=None):
        # use first argument as the internal error message if provided
        if args:
            self.internal_err_msg = args[0]
            super().__init__(*args)
        else:
            super().__init__(self.internal_err_msg)

        if user_err_msg is not None:
            self.user_err_msg = user_err_msg

    def log_exception(self):
        exception_data = {
            "type": type(self).__name__,
            "http_status": self.http_status,
            "message": self.internal_err_msg,
            "args": self.args[1:],
            "timestamp": datetime.utcnow().isoformat()
        }
        print(f"LOG_EXCEPTION: {exception_data}")

    def to_json(self):
        return json.dumps({
            "status": self.http_status,
            "message": self.user_err_msg
        })

すべてのサブクラスは `http_status` や `internal_err_msg`、`user_err_msg` を自由に上書きできます:

class NotFoundError(APIException):
    http_status = HTTPStatus.NOT_FOUND
    internal_err_msg = "Resource was not found."
    user_err_msg = "Requested resource was not found."

こうしておけば、`APIException`(またはそのサブクラス)をキャッチして、簡単にログを取って JSON レスポンスを整形し、クライアントに返せるようになります:

def get_resource(resource_id):
    try:
        resource = load_resource(resource_id)
    except APIException as ex:
        ex.log_exception()
        return ex.to_json()
    else:
        return HTTPStatus.OK, {"id": resource_id}

例外の多重継承 (Multiple Inheritance)

単一継承を超えた話になりますが、Python は 多重継承 をサポートします。ある例外を同時に `AppException` と `ValueError` の両方として扱いたい場合、単にクラス定義で2つの親を列挙するだけです。

class NegativeIntegerError(AppException, ValueError):
    """An error that is both a ValueError and an AppException."""

すると、`NegativeIntegerError` は `AppException` と `ValueError` の両方のサブクラスになり、コード側ではどちらの例外型でもキャッチできるようになります。


総合まとめ

  1. 例外の発生 (raise): `raise` を使って意図的に例外ワークフローを開始できる。

  2. 再送出 (re-raise): `except` ブロック内で引数なしの `raise` を使えば、もとの例外がそのまま上位へ伝播する。

  3. `from ...` によるトレースバック制御: どのレベルまでの例外履歴を見せるかは柔軟に選べる。`from None` で中間の詳細を隠したり、特定の例外のみ原因として指定したりできる。

  4. カスタム例外クラス: ドメイン固有のエラーを定義して、共通の基底クラスを作ることで挙動を統一できる。ログや JSON 形式出力なども組み込める。

  5. 階層構造: Python の例外と同様に、抽象的な基底クラスから具体的な派生クラスへ階層化する。異なる粒度での例外ハンドリングが可能になる。

  6. 多重継承: 1つの例外を複数の型 (例: `ValueError` とカスタム例外) のサブクラスにできる。

これらのテクニックを活用すれば、プログラムをただ「落とす」のではなく、より意味のあるフィードバックを提供したり、逆に詳細を隠したりと、状況に応じた柔軟なエラー処理を行えるようになります。


「超温和なパイソン」へ

いいなと思ったら応援しよう!