JEPA4Japan · チュートリアル

例外と入力検証

1,922文字 6分で読めます #Python

不正な状態を検出し、想定内の失敗から回復し、役立つエラーを伝えます。

コース進捗 コース目次 24レッスン中 24件を公開中

例外は警報

第9章では、関数に契約を与えました。入力がその約束を破ったらどうなるでしょう。Pythonは今の道を止めて、例外を送出します。例外は怪物ではなく、役立つ情報です。

  1. 止まる 操作を完了できない
  2. 読む 最後の一行から
  3. 名前を知る 型とメッセージ
  4. 選ぶ 直す、拒否する、回復する
トレースバックは、何が失敗し、どこを見るかを教えます。

次の一行は、わざと失敗します。

minutes = int("forty")

トレースバックの最後はValueErrorのメッセージです。まずその一行を読み、次に上へ進んで自分のファイルの行を探します。型が違えば、壊れた約束も違います。int(None)はTypeError、10 / 0はZeroDivisionErrorです。

分かる警報だけを捕まえる

tryブロックは小さくします。自分が扱うつもりの危険な操作だけを入れましょう。

raw_minutes = "forty"

try:
    minutes = int(raw_minutes)
except ValueError:
    print("分数は整数で指定してください。")
else:
    print("受理:", minutes)

出力:

分数は整数で指定してください。

except ValueErrorは、理解できる変換失敗だけを扱います。とにかく先へ進むためのexcept:やexcept Exceptionは避けます。名前の書き間違いや壊れた計算まで隠すからです。

後の整形はelseへ置きます。すると整形の不具合を、不正な入力と間違えません。複数の型をまとめるのは、その境界で本当に同じ扱いにするときだけです。たとえば文字列またはNoneを受ける境界なら、except (TypeError, ValueError)にできます。

四つの道には、四つの仕事

  1. try 危険な一歩を試す
  2. except 想定した失敗を扱う
  3. else 成功した結果を使う
  4. finally 必ず後片付けする
各節は、プログラムが通る別々の道を表します。
def show_average(total, count):
    try:
        average = total / count
    except ZeroDivisionError:
        print("平均を出す記録がありません。")
    else:
        print(f"平均:{average:.1f}")
    finally:
        print("計算終了。")


show_average(90, 3)
show_average(0, 0)

出力:

平均:30.0
計算終了。
平均を出す記録がありません。
計算終了。

elseは成功後だけ、finallyは成功、捕捉した例外、捕捉しない例外の後にも動きます。本当に必要な後片付けに使い、エラーや戻り値を隠すためには使いません。第12章では、ファイル用のもっと簡単な後片付け道具を学びます。

検証は門。推測ではない

変換と、意味の確認は別々の門です。"0"は整数の0へ変換できますが、0を許さないアプリもあります。

  1. 生の入力 "45" または "many"
  2. 変換する 整数にできる?
  3. 検証する 1から1440まで?
二つの門を通ったデータだけが、信頼できるデータになります。
def parse_minutes(raw_minutes):
    try:
        minutes = int(raw_minutes)
    except (TypeError, ValueError) as cause:
        raise ValueError("分数は整数で指定してください") from cause

    if not 1 <= minutes <= 1440:
        raise ValueError("分数は1から1440までです")
    return minutes

raise ... from causeは、呼び出し側へ安定したメッセージを渡しながら、元の変換失敗をerror.__cause__とトレースバックに残します。

オブジェクトの種類が違うならTypeError、種類は合っていて値が不正ならValueErrorを使います。必須の不正データを、勝手に0へ変えてはいけません。小さな端:int(True)は1になります。真偽値を分数として認めない契約なら、変換前にはっきり拒否します。

信頼できる学習ゲートを作る

次の完全なプログラムをstudy_gate.pyとして保存します。

def normalize_topic(raw_topic):
    if not isinstance(raw_topic, str):
        raise TypeError("テーマは文字列で指定してください")
    topic = raw_topic.strip()
    if not topic:
        raise ValueError("テーマを空にはできません")
    return topic


def parse_minutes(raw_minutes):
    if isinstance(raw_minutes, bool):
        raise TypeError("分数に真偽値は使えません")
    try:
        minutes = int(raw_minutes)
    except (TypeError, ValueError) as cause:
        raise ValueError("分数は整数で指定してください") from cause
    if not 1 <= minutes <= 1440:
        raise ValueError("分数は1から1440までです")
    return minutes


def make_session(raw_topic, raw_minutes):
    return {
        "topic": normalize_topic(raw_topic),
        "minutes": parse_minutes(raw_minutes),
    }


def process_entries(raw_entries):
    accepted = []
    problems = []
    for number, (topic, minutes) in enumerate(raw_entries, start=1):
        try:
            session = make_session(topic, minutes)
        except (TypeError, ValueError) as error:
            problems.append(f"記録{number}:{error}")
        else:
            accepted.append(session)
    return accepted, problems


def format_report(sessions, problems):
    total = 0
    for session in sessions:
        total += session["minutes"]
    lines = [
        f"受理:{len(sessions)}件",
        f"拒否:{len(problems)}件",
        f"信頼できる分数:{total}分",
    ]
    for problem in problems:
        lines.append("- " + problem)
    return "\n".join(lines)


def run_checks():
    assert normalize_topic(" Python ") == "Python"
    assert parse_minutes("45") == 45
    try:
        parse_minutes("many")
    except ValueError as error:
        assert str(error) == "分数は整数で指定してください"
        assert isinstance(error.__cause__, ValueError)
    else:
        raise AssertionError("不正な分数が受理されました")


run_checks()
raw_entries = [
    (" Python ", "45"),
    ("Git", "many"),
    ("   ", "15"),
    ("SQL", "30"),
    ("テスト", "0"),
]
sessions, problems = process_entries(raw_entries)

print("確認に合格しました。")
print(format_report(sessions, problems))

python3 study_gate.pyで実行します。

確認に合格しました。
受理:2件
拒否:3件
信頼できる分数:75分
- 記録2:分数は整数で指定してください
- 記録3:テーマを空にはできません
- 記録5:分数は1から1440までです

コレクションの境界は、想定した検証失敗だけを捕捉します。プログラムの不具合は「不正な入力」と呼ばれず、見える状態のままです。

3つの小さなチャレンジ

  1. 1から5までの整数を扱うparse_rating()を書き、0、1、5、6を試す。
  2. 辞書からminutesキーを消し、KeyErrorだけを捕捉する。成功時の表示はelseへ置く。
  3. プロジェクトのデータへ(42, "10")と("Git", True)を足し、二つのメッセージを予想する。

第11章へ進めるかな?

  • トレースバックを最後の一行から読める
  • tryを小さくし、想定した例外型だけを捕捉できる
  • except、else、finallyを説明できる
  • 変換してから、意味を検証できる
  • raise ... from ...で元の原因を残せる
  • 学習ゲートを実行し、すべての確認に合格した

次章では、信頼できる助手をモジュールとパッケージへ移し、プロジェクトごとの実行環境を作ります。