【Python】例外を握りつぶさないtry・except・else・finallyの使い分け

PythonのTopに戻る

回復できる失敗だけを捕まえる

入力を数値へ直す処理で失敗しても次へ進みたい場合、tryとexceptが使える。ただし解析全体を大きなtryで囲み、すべての例外を無視すると、入力ミスとプログラムの不具合が区別できなくなる。失敗を回復できる小さな範囲だけをtryへ入れ、正常に読み取れた後の処理をelse、必ず必要な後始末をfinallyへ分ける。

以下では文字列から整数を読み、変換関数へ渡す例を使う。入力が整数でなければNoneを返す一方、変換関数自体の不具合は外へ伝える。StringIOはメモリ上のファイルのように振る舞うため、実ファイルに触れずにclose()まで確認できる。イベントのリストは、どの分岐を通ったかを見るための小さな記録である。

成功・入力エラー・処理の不具合を分ける

example.py

from io import StringIO


def parse_count(text, transform, events):
    stream = StringIO(text)
    try:
        value = int(stream.read())
    except ValueError:
        events.append("invalid input")
        return None
    else:
        events.append("parsed")
        return transform(value)
    finally:
        stream.close()
        events.append(f"closed={stream.closed}")


def double(value):
    return value * 2


def broken_transform(value):
    raise ValueError("conversion bug")


events = []
assert parse_count("12", double, events) == 24
assert events == ["parsed", "closed=True"]
print("normal:", events)
events = []
assert parse_count("bad", double, events) is None
assert events == ["invalid input", "closed=True"]
print("invalid:", events)
events = []
try:
    parse_count("12", broken_transform, events)
except ValueError as error:
    assert str(error) == "conversion bug"
    assert events == ["parsed", "closed=True"]
    print("propagated:", str(error), events)
else:
    raise AssertionError("conversion bug was swallowed")

実行結果

normal: ['parsed', 'closed=True']
invalid: ['invalid input', 'closed=True']
propagated: conversion bug ['parsed', 'closed=True']

elseへ置くと捕まえる範囲が狭くなる

tryに入っているのは整数への変換だけである。int()がValueErrorを送出した場合に限り、入力ミスとして扱う。elseはtryが例外なく終わったときに実行されるので、その中のtransform()で起きたValueErrorは同じexceptでは捕まらない。例ではわざと変換関数から同じ種類の例外を出し、外へ伝わることを確かめた。

もしtransform()までtryに入れていたら、内部の不具合も「入力が整数でない」という分岐へ入ってしまう。例外の種類だけで原因を判断するのではなく、どの処理の失敗を回復対象にするかを範囲で示すことが重要である。処理を小さな関数へ分けると、この範囲も読みやすくなる。

Noneは「整数が読めなかった」という戻り値の契約である。0は正しい整数なので、if result:のように真偽値だけで判定せず、is Noneで失敗を確認する方がよい。正常値と区別しにくい戻り値になるなら、例外をそのまま呼び出し側へ伝える設計も選べる。

finallyは戻り値の上書きに使わない

tryやexceptやelseでreturnしても、その前にfinallyが実行される。例の成功時も入力エラー時も、返る前にstreamが閉じられる。処理が外へ例外を伝える場合も、通常はfinallyを通ってから伝わる。ここへ後始末をまとめると、各分岐へclose()を何度も書く必要がなくなる。

finallyの中でreturnすると、先に返そうとしていた値や発生した例外を隠してしまうことがある。後始末の場所で成功値を作るのは避けたい。また、close()などの後始末自体が失敗する場合もあり、元の失敗を含めて調べられるように記録する設計が必要になる。

通常のファイルやロックなど、コンテキストマネージャーを持つ資源ではwithを使う方が簡潔である。この例は構文の働きを観察するためにclose()を明示した。withを使う場合でも、どの例外を回復するか、後続処理をどこへ置くかという考え方は変わらない。

広すぎるexceptを避ける

except Exceptionで何でもNoneへ変えると、綴り間違いによるNameErrorなども隠す。さらに型を付けないexceptはKeyboardInterruptやSystemExitまで捕まえるので、利用者が停止させたい処理を続けてしまう場合がある。予想する例外を列挙し、想定外の問題は外へ伝えることを基本にする。

最上位の処理でログを残したい場合には、広く捕まえて記録し、引数のないraiseで再送出する方法がある。ログを書いたことと回復できたことは同じではない。再試行するなら回数と条件を決め、入力を変えないまま永久に同じ失敗を繰り返さない。成功・回復・伝播の三つが分かるテストを用意すると、例外処理の変更を確認しやすい。

実行環境と関連情報

掲載コードはLinux上のCPython 3.12.14で実行した。OS固有のファイル操作や対話環境の違いは、本文に記した条件に従って扱う。

関連:独自例外とraise fromで「何に失敗したか」を伝える / pytestで正常系と異常系の自動テストを始める

PythonのTopに戻る