モダンPython基礎 #6 エラーと例外処理

読了 7分

#5 関数 — 引数パターン で関数の表現力を押さえました。今回は その関数が失敗するときどう扱うか — 例外処理です。

Python は 例外 (exception) でエラーを表す言語です。他の言語のようにエラーコードを返すスタイルではなく、「異常事態は投げて、受け取る人がキャッチする」が基本です。EAFP (Easier to Ask for Forgiveness than Permission) という表現でよく呼ばれます。

最も簡単な try / except #

基本の try/except
def divide(a: int, b: int) -> float:
    try:
        return a / b
    except ZeroDivisionError:
        return float("inf")

print(divide(10, 2))   # 5.0
print(divide(10, 0))   # inf

文法:

  • try: ブロックの中で例外が発生しうるコードを実行
  • except 例外型: でキャッチ
  • キャッチされなかった例外は上に伝播 (caller に上がる)

LBYL vs EAFP #

他の言語は通常「使う前に確認」(LBYL — Look Before You Leap) スタイルです。

LBYL スタイル
if b != 0:
    result = a / b
else:
    result = float("inf")

Python は EAFP の方が慣用的です。

EAFP スタイル — Pythonic
try:
    result = a / b
except ZeroDivisionError:
    result = float("inf")

理由: Python は動的型付けなので 「条件を正確に事前確認する」 のが難しい場面が多く、例外処理のコストも (例外が頻発する場合でなければ) 低いからです。

複数の例外をキャッチ #

複数例外
def parse_int(value: str) -> int | None:
    try:
        return int(value)
    except (ValueError, TypeError):
        return None

print(parse_int("42"))   # 42
print(parse_int("abc"))  # None
print(parse_int(None))   # None

(例外1, 例外2) のように tuple でまとめてキャッチします。

別々に処理したいなら分離 #

それぞれ別の処理
try:
    do_work()
except ValueError as e:
    print(f"不正な値: {e}")
except KeyError as e:
    print(f"キーがない: {e}")
except Exception as e:
    print(f"その他: {e}")
    raise   # 再度投げる

as e で例外オブジェクトを受け取ります。メッセージ (str(e)) や原因 (e.__cause__) のような情報を取り出せます。

elsefinally #

try には二つの追加節があります。

else / finally
def read_file(path: str) -> str:
    f = None
    try:
        f = open(path)
        data = f.read()
    except FileNotFoundError:
        return ""
    else:
        # 例外なしで try が終わったときだけ実行
        print(f"読み取り成功: {len(data)} bytes")
        return data
    finally:
        # 例外発生の有無に関係なく常に実行
        if f is not None:
            f.close()
  • elsetry が例外なく終わったときだけ。「成功時にだけやること」がある場面
  • finally — 例外の有無と関係なく 常に。リソース整理 (ファイル close、ロック解放) に使う

上のコードは実は with 文でもっとすっきり書けます。

with — リソース整理は普通こちら
def read_file(path: str) -> str:
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError:
        return ""

withfinally の close 責任を引き受けます。次のシリーズ (Python 中級) でコンテキストマネージャとして詳しく扱うトピックです。

raise — 自分で投げる #

raise
def withdraw(balance: int, amount: int) -> int:
    if amount <= 0:
        raise ValueError("出金額は正の数でなければなりません")
    if amount > balance:
        raise ValueError(f"残高不足: {balance} < {amount}")
    return balance - amount

raise の後に例外インスタンス (またはクラス) を書きます。raise ValueError (クラスのみ) も可能ですが、メッセージも一緒に書く raise ValueError("...") が一般的です。

再投げ #

except の中で処理はしたが呼び出し元にも知らせたいとき。

re-raise
try:
    do_work()
except KeyError:
    log("キーなし発生")
    raise   # 引数なしの raise — キャッチした例外をそのまま再度投げる

raise だけ書くと 現在キャッチした例外をそのまま 上に投げます。trace が保存されます。

raise ... from ... — 原因の連鎖 #

元の例外を別の例外で包みたいとき。

from で因果関係を連結
def load_config(path: str) -> dict:
    try:
        with open(path) as f:
            return parse(f.read())
    except (OSError, ValueError) as e:
        raise ConfigError(f"設定ロード失敗: {path}") from e

from e があると traceback に 「この例外の直接の原因は e」 という連結が表示されます。デバッグ時に本当に役立ちます。

例外階層 #

Python の例外はクラス階層です。親をキャッチすれば子もすべてキャッチされます。

よく出会う階層 (簡略)
BaseException
 ├─ SystemExit          ← sys.exit()
 ├─ KeyboardInterrupt   ← Ctrl+C
 └─ Exception           ← 通常はここ以下のみキャッチ
     ├─ ValueError
     │   └─ UnicodeError
     ├─ TypeError
     ├─ LookupError
     │   ├─ KeyError
     │   └─ IndexError
     ├─ ArithmeticError
     │   └─ ZeroDivisionError
     ├─ OSError
     │   └─ FileNotFoundError
     └─ ...

重要なルール: 一般的なコードでは except Exception までだけキャッチしてください。except BaseException や bare except:KeyboardInterrupt のようなシステムシグナルまで飲み込んで、Ctrl+C が効かない事故を作ります。

❌ 絶対だめ
try:
    risky()
except:           # bare except — KeyboardInterrupt もキャッチされる
    pass

# あるいは
except BaseException:   # 同じ問題
    pass
✅ すべての一般例外をキャッチ
try:
    risky()
except Exception as e:
    log(e)

ユーザー定義例外 #

ライブラリ / モジュールを作るときは、自分の例外クラス を定義しておくのが良いです。呼び出し元が何をキャッチすべきかを明示できます。

ユーザー定義例外
class AppError(Exception):
    """このアプリのすべての例外のベース。"""

class ConfigError(AppError):
    """設定関連エラー。"""

class DatabaseError(AppError):
    """DB 関連エラー。"""

class RowNotFoundError(DatabaseError):
    """必要な行がありません。"""
    def __init__(self, table: str, key: str):
        super().__init__(f"{table}{key} が見つからない")
        self.table = table
        self.key = key

呼び出し側は:

呼び出し側
try:
    user = repo.get_user("u1")
except RowNotFoundError as e:
    return Response(status=404)
except DatabaseError as e:
    log_critical(e)
    return Response(status=500)

それぞれの場面で 必要な分だけ キャッチします。

finally の罠 — return の優先順位 #

finallyreturn すると try の return を上書きします。ほぼアンチパターンです。

🚫 紛らわしいコード
def f() -> int:
    try:
        return 1
    finally:
        return 2   # こちらが勝つ

print(f())   # 2

finally副作用の整理 だけにとどめ、returnraise はできるだけしない のが良いです。

Exception Group — except* (3.11+) #

複数の作業が 同時に 失敗しうる場面 (asyncio の TaskGroup、並列作業) で、複数の例外を一度にまとめて投げられる新しいメカニズムが入りました。これが ExceptionGroup で、それをキャッチする文法が except* です。

ExceptionGroup を作る
errors = ExceptionGroup(
    "複数の作業が失敗",
    [ValueError("a"), TypeError("b"), ValueError("c")],
)
raise errors

except* はグループの中から 該当タイプの例外だけ拾い 、残りは再度投げます。

except* の使用
try:
    raise ExceptionGroup(
        "複数の失敗",
        [ValueError("a"), TypeError("b"), ValueError("c")],
    )
except* ValueError as eg:
    print("値エラーたち:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
    print("型エラーたち:", [str(e) for e in eg.exceptions])

いつ出会うのか? asyncio.TaskGroup で複数の task を同時に回すとき、そのうち二つ以上が失敗すると自動で ExceptionGroup が作られます。非同期は次のシリーズで扱いますが、「複数の例外が一度に来うる場面がある」という事実だけ覚えておいてください。

assert — 開発中の仮説検査 #

assert
def calc_average(items: list[int]) -> float:
    assert len(items) > 0, "空リストでは平均が出せない"
    return sum(items) / len(items)

assert 条件, メッセージ — 条件が False なら AssertionError が起きます。

注意: python -O (optimization) モードではすべての assert削除されます。 したがって:

  • 開発中の仮説検査 (「ここでは絶対 None ではないはず」のようなもの)
  • ユーザー入力の検証 — これは常に通常の if + raise でやるべき
仮説 vs 入力検証
def serve(user: User | None):
    # ✅ システム仮説: 認証ミドルウェアが None を防いだはず
    assert user is not None, "auth middleware 抜け?"

    # ❌ ユーザー入力は assert で防いではダメ
    # assert age >= 0, "負の年齢不可"  ← -O で消える
    if age < 0:
        raise ValueError("年齢は 0 以上")

良い例外処理の感覚 #

最後にコードレビューでよく登場するパターンを整理します。

1) 広くキャッチしすぎない #

❌ 広すぎる
try:
    process(item)
except Exception:
    pass    # すべての例外を無視 — バグを隠す
✅ キャッチしたいものだけ
try:
    process(item)
except ValueError as e:
    log(f"不正な項目: {e}")

2) try ブロックは短く #

❌ try の中身が多すぎる
try:
    a = compute()
    b = transform(a)
    c = validate(b)
    d = save(c)
    return d
except SomeError:
    ...

SomeError がどの行で発生したか分かりません。怪しい一行だけを囲んでください。

3) pass だけの except は疑う #

空の except: pass はほぼ常にバグの始まりです。本当に無視してよい場面なら 理由をコメントで残すか、少なくともログは残してください。

4) 通常のフローに例外を使わない #

例外は 異常事態 用です。通常の分岐まで try/except で書くと、コードを読む人が罠にはまります (ただし Python は EAFP が慣用的なので、ある場面ではそれが正常 — int("abc") のような変換失敗処理など)。

まとめ #

今回押さえたもの:

  • EAFP — Python では試してキャッチする方が自然な場面が多い
  • try / except / else / finally の使い分け
  • リソース整理は finally よりも with の方が普通きれい
  • raiseraise ... from ... (原因の連結)
  • except Exception まで — BaseException や bare except: はダメ
  • ユーザー定義例外はベースクラスを置いて階層で作る
  • assert は仮説検査用、入力検証は絶対 if + raise
  • ExceptionGroup + except* (3.11+) — 複数例外の同時処理 (asyncio TaskGroup など)

次回 (#7 モジュール / パッケージと pyproject.toml) では 複数のファイルにコードを分ける方法import システム、モジュールとパッケージの違い、__init__.py__main__、そして pyproject.toml まで一箇所に整理します。

X