エラーと例外処理
try/except/else/finally の役割、raise とユーザー定義例外、そして 3.11 がもたらした ExceptionGroup と except* までまとめます。
第5章 関数 — 引数パターン で関数の表現力を押さえました。本章は その関数が失敗するときにどう扱うか — 例外処理です。
本章の最後で扱う ExceptionGroup + except* は、第14章 非同期入門 (asyncio) と第18章 非同期の深層 の TaskGroup で再び出会います。非同期では同時に複数の作業が進むので、同時に複数の例外が発生し得ます。そこに ExceptionGroup が登場します。本章で形を先に押さえておくと第14章が軽くなります。
Python は 例外(exception) で誤りを表現する言語です。他の言語のようにエラーコードを返すスタイルではなく、「異常な状況は投げて、受ける人が捕まえる」が基本です。EAFP (Easier to Ask for Forgiveness than Permission) という呼び方をよくします。
もっとも単純な 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) スタイルです。
if b != 0:
result = a / b
else:
result = float("inf")Python は EAFP がより慣用的です。
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__) などの情報を取り出せます。
else と finally
#
try には二つの追加節があります。
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()else—tryが例外なしで終わったときだけ。「成功時のみやること」がある場合finally— 例外の有無と無関係に 常に。リソース整理(ファイル close、ロック解放) に使う
上のコードは実は with 文でよりシンプルに書けます。
def read_file(path: str) -> str:
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
return ""with が finally の close 責任を引き受けます。第10章 コンテキストマネージャ (with, contextlib) で詳しく扱います。
raise — 自分で投げる
#
def withdraw(balance: int, amount: int) -> int:
if amount <= 0:
raise ValueError("出金額は正の数でなければなりません")
if amount > balance:
raise ValueError(f"残高不足: {balance} < {amount}")
return balance - amountraise の後ろに例外インスタンス (またはクラス) を書きます。raise ValueError (クラスのみ) も可能ですが、メッセージを一緒に書く raise ValueError("...") が一般的です。
再送出 #
except の中で処理はしたが、呼び出し元にも通知したいとき。
try:
do_work()
except KeyError:
log("キーがない発生")
raise # 引数なしで raise — 捕まえた例外をそのまま再送出raise だけ書くと 現在捕まえている例外をそのまま 上に投げます。trace が保持されます。
raise ... 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 efrom 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 やすべてを受ける except: は KeyboardInterrupt のようなシステムシグナルまで飲み込んでしまい、Ctrl+C が効かなくなる事故を生みます。
try:
risky()
except: # bare except — KeyboardInterrupt も捕まる
pass
# あるいは
except BaseException: # 同じ問題
passtry:
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 の優先順位
#
finally で return すると try の return を上書きします。ほぼアンチパターンです。
def f() -> int:
try:
return 1
finally:
return 2 # こちらが勝つ
print(f()) # 2finally は 副作用の後片付け だけにし、return や raise はできるだけしない のが好ましいです。
Exception Group — except* (3.11+)
#
複数の作業が 同時に 失敗し得る状況(asyncio の TaskGroup、並列作業) で、一度に複数の例外をまとめて投げられる新しい仕組みが入りました。それが ExceptionGroup であり、それを捕まえる文法が except* です。
errors = ExceptionGroup(
"複数の作業が失敗",
[ValueError("a"), TypeError("b"), ValueError("c")],
)
raise errorsexcept* はグループの中から 該当タイプの例外だけを選んで捕まえ、残りは再送出します。
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 が作られます。第14章 非同期入門 (asyncio) で本パターンに再び出会います。
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で行うべき
def serve(user: User | None):
# ✅ システム前提: 認証ミドルウェアが None を防いだはず
assert user is not None, "auth middleware が抜けている?"
# ❌ ユーザー入力を assert で防ぐのは NG
# 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:
a = compute()
b = transform(a)
c = validate(b)
d = save(c)
return d
except SomeError:
...SomeError がどの行で起きたか分かりません。怪しい一行だけ包んでください。
3) pass だけの except は疑う
#
空の except: pass はほぼ必ずバグの始まりです。本当に無視してよい場合でも、理由をコメントで残すか、せめてロギングはしてください。運用環境のロギング標準は第31章 logging と観測性 で扱います。
4) 通常の流れに例外を使わない #
例外は 異常な状況 用です。通常の分岐まで try / except で書くと、コードを読む人が落とし穴にはまります。 (ただし Python は EAFP が慣用的なので、ある種の場合はそれが正常です — int("abc") のような変換失敗処理など。)
練習問題 #
def safe_int(value: str) -> int | None:のシグネチャでint(value)を試み、ValueErrorならNoneを返す関数を書いてください。TypeError(None が渡る場合) も一緒に捕まえるように tuple でまとめます。- ユーザー定義例外階層
AppError→ValidationError/NotFoundErrorを作ってください。def find_user(id: int) -> User:がid <= 0ならValidationError、DB に存在しなければNotFoundErrorを投げるように書きます。呼び出し側で二つの例外をそれぞれ別のメッセージで処理するコードを書いてください。 ExceptionGroupを直接作ってみてください。三つの作業の結果を集めて、二つ以上がValueErrorならExceptionGroup("一括失敗", [...])でまとめて投げ、呼び出し側はexcept* ValueErrorで受けてメッセージリストを出力します。
一行まとめ: Python は EAFP — 試してから捕まえるのが自然。
try/except/else/finallyの役割ははっきり違い、リソース整理は通常withが読みやすい。except Exceptionまでで止め、bare except とBaseExceptionは禁止。ユーザー定義例外は階層で、assertは前提チェック用のみ。3.11+ のExceptionGroup+except*は非同期の同時失敗用。
次の章 #
次の 第7章 モジュール、パッケージと pyproject.toml では、複数のファイルにコードを分ける方法 — import システム、モジュールとパッケージの違い、__init__.py、__main__、そして pyproject.toml までを一箇所にまとめます。1部の最終章です。