26. 例外とバリデーションを使った防御的プログラミング手法
防御的プログラミング(defensive programming)とは、問題が起きる前にそれを想定してコードを書くことです。すべてが完璧に動くと決めつけるのではなく、防御的なコードは入力を検証し、エラーを丁寧に処理し、前提をチェックします。このアプローチにより、より信頼性が高く、デバッグしやすく、予期せずクラッシュしにくいプログラムを作れます。
前の章では、例外が起きたときにそれを処理する方法を学びました。ここでは、多くのエラーをそもそも発生させない方法と、発生した場合に早い段階で問題を捉える方法を学びます。
26.1) 関数の引数を検証する
関数(function)は、プログラムの別の部分やユーザーからデータを受け取ることがよくあります。関数が不正なデータを受け取ると、誤った結果を出したり、わかりにくいエラーでクラッシュしたり、プログラムの他の部分で問題を引き起こしたりする可能性があります。引数の検証(argument validation)とは、関数の引数を使う前に、それらが要件を満たしているかを確認することです。
26.1.1) なぜ引数を検証するのか?
次の、学生の成績の割合を計算する関数を考えてみましょう。
def calculate_percentage(points_earned, total_points):
return (points_earned / total_points) * 100
# 関数を使用する
percentage = calculate_percentage(85, 100)
print(f"Grade: {percentage}%") # Output: Grade: 85.0%これは有効な入力では問題なく動きます。では、問題のあるデータではどうなるでしょうか?
# 問題1: 0で割る
percentage = calculate_percentage(85, 0) # ZeroDivisionError!
# 問題2: 負の値(意味がない)
percentage = calculate_percentage(-10, 100) # -10.0%
# 問題3: 獲得点が満点を超えている(あり得ない)
percentage = calculate_percentage(120, 100) # 120.0%検証がない場合、関数はクラッシュするか、意味のない結果を出します。エラーメッセージはビジネスロジックの観点で何が問題なのかを説明せず、技術的な失敗を示すだけです。
26.1.2) 条件分岐による基本的な引数検証
最もシンプルな検証方法は、if 文を使って引数をチェックし、無効な場合に例外を送出することです。
def calculate_percentage(points_earned, total_points):
# total_points を検証する
if total_points <= 0:
raise ValueError("total_points must be positive")
# points_earned を検証する
if points_earned < 0:
raise ValueError("points_earned cannot be negative")
if points_earned > total_points:
raise ValueError("points_earned cannot exceed total_points")
# すべての検証に合格 - 安全に計算できる
return (points_earned / total_points) * 100
# 正しい使用例
percentage = calculate_percentage(85, 100)
print(f"Grade: {percentage}%") # Output: Grade: 85.0%
# 不正な使用例 - 明確なエラーメッセージ
try:
percentage = calculate_percentage(85, 0)
except ValueError as e:
print(f"Error: {e}") # Output: Error: total_points must be positive
try:
percentage = calculate_percentage(-10, 100)
except ValueError as e:
print(f"Error: {e}") # Output: Error: points_earned cannot be negative
try:
percentage = calculate_percentage(120, 100)
except ValueError as e:
print(f"Error: {e}") # Output: Error: points_earned cannot exceed total_pointsこれで何か問題が起きたとき、エラーメッセージが問題点と修正方法を明確に説明してくれます。
26.1.3) 引数の型を検証する
場合によっては、引数が正しい型であることを保証する必要があります。
def calculate_discount(price, discount_percent):
# 型を検証する
if not isinstance(price, (int, float)):
raise TypeError("price must be a number")
if not isinstance(discount_percent, (int, float)):
raise TypeError("discount_percent must be a number")
# 値を検証する
if price < 0:
raise ValueError("price cannot be negative")
if not (0 <= discount_percent <= 100):
raise ValueError("discount_percent must be between 0 and 100")
# 割引を計算する
discount_amount = price * (discount_percent / 100)
return price - discount_amount
# 正しい使用例
final_price = calculate_discount(50.00, 20)
print(f"Final price: ${final_price:.2f}") # Output: Final price: $40.00
# 型エラー
try:
final_price = calculate_discount("50", 20)
except TypeError as e:
print(f"Error: {e}") # Output: Error: price must be a number
# 値エラー
try:
final_price = calculate_discount(50.00, 150)
except ValueError as e:
print(f"Error: {e}") # Output: Error: discount_percent must be between 0 and 100isinstance() 関数は、オブジェクトが指定した型(または複数型)のインスタンスかどうかをチェックします。ここでは (int, float) というタプルを渡して、整数または浮動小数点数のどちらも受け付けています。価格としては両方とも有効な数値型だからです。
型を検証するタイミング: Python の考え方は「ダックタイピング(duck typing)」です。つまり、必要な振る舞いをするオブジェクトなら使う、というものです。型の検証が特に有用なのは次のような場合です。
- 他の人が使う関数を書いている
- 型エラーが後になってわかりにくい失敗を引き起こす
- 関数が公開 API やライブラリの一部である
26.1.4) コレクション引数を検証する
関数がリスト(list)、辞書(dictionary)などのコレクションを受け取る場合は、コレクションそのものと、その中身の両方を検証します。
def calculate_average_grade(grades):
# コレクション自体を検証する
if not isinstance(grades, list):
raise TypeError("grades must be a list")
if len(grades) == 0:
raise ValueError("grades list cannot be empty")
# コレクション内の各 grade を検証する
for i, grade in enumerate(grades):
if not isinstance(grade, (int, float)):
raise TypeError(f"grade at index {i} must be a number, got {type(grade).__name__}")
if not (0 <= grade <= 100):
raise ValueError(f"grade at index {i} must be between 0 and 100, got {grade}")
# すべての検証に合格
return sum(grades) / len(grades)
# 正しい使用例
grades = [85, 92, 78, 95]
average = calculate_average_grade(grades)
print(f"Average: {average:.1f}") # Output: Average: 87.5
# 空リストのエラー
try:
average = calculate_average_grade([])
except ValueError as e:
print(f"Error: {e}") # Output: Error: grades list cannot be empty
# 不正な grade の型
try:
average = calculate_average_grade([85, "92", 78])
except TypeError as e:
print(f"Error: {e}") # Output: Error: grade at index 1 must be a number, got str
# 不正な grade の値
try:
average = calculate_average_grade([85, 92, 150])
except ValueError as e:
print(f"Error: {e}") # Output: Error: grade at index 2 must be between 0 and 100, got 150コレクション要素を検証するとき、エラーメッセージにインデックスを含めている点に注目してください。これにより、特に大きなコレクションでは、どの項目が問題なのかを正確に特定できます。
26.2) ユーザー入力の妥当性を確認する
ユーザー入力は本質的に信頼できません。ユーザーはタイプミスをしたり、指示を誤解したり、想定外の形式でデータを入力したりします。ユーザー入力を検証することで、こうしたミスがプログラムのクラッシュや誤った結果につながるのを防げます。
26.2.1) 基本的な入力検証パターン
入力検証の基本パターンは、input() と検証チェックを組み合わせます。
# ユーザー入力を取得する
age_str = input("Enter your age: ")
# 入力を検証する
try:
age = int(age_str)
if age < 0:
print("Error: Age cannot be negative")
elif age > 150:
print("Error: Age seems unrealistic")
else:
print(f"You are {age} years old")
except ValueError:
print("Error: Please enter a valid number")このパターンは3つの部分で構成されます。
- 入力を文字列として取得する
- 必要な型に変換を試みる
- 変換後の値が有効かどうかをチェックする
さまざまな入力でこれがどう動くか見てみましょう。
# Valid input
# User enters: 25
# Output: You are 25 years old
# Invalid type
# User enters: twenty-five
# Output: Error: Please enter a valid number
# Invalid value (negative)
# User enters: -5
# Output: Error: Age cannot be negative
# Invalid value (unrealistic)
# User enters: 200
# Output: Error: Age seems unrealistic26.2.2) 入力範囲と形式を検証する
入力によっては、特定の範囲に収まる必要があったり、特定の形式に一致する必要があったりします。
# 月(1-12)を検証する
month_str = input("Enter month (1-12): ")
try:
month = int(month_str)
if not (1 <= month <= 12):
print("Error: Month must be between 1 and 12")
else:
print(f"Month: {month}")
except ValueError:
print("Error: Please enter a whole number")
# メール形式を検証する(簡易チェック)
email = input("Enter email: ")
if '@' not in email or '.' not in email:
print("Error: Email must contain @ and .")
else:
print(f"Email: {email}")
# yes/no 入力を検証する
response = input("Continue? (yes/no): ").lower().strip()
if response not in ['yes', 'no', 'y', 'n']:
print("Error: Please answer yes or no")
else:
if response in ['yes', 'y']:
print("Continuing...")
else:
print("Stopping...")ここでのメール検証は意図的に簡単にしています。@ と . を含むかどうかという基本構造をチェックしているだけです。実際のメール検証はもっと複雑で、通常は正規表現(regular expressions)を使います(第39章で学びます)。
26.2.3) 役に立つエラーメッセージを提示する
良いエラーメッセージは、何が問題だったのかと、どう直せばよいのかをユーザーに正確に伝えます。
# 悪いエラーメッセージ
password = input("Enter password: ")
if len(password) < 8:
print("Error: Invalid password") # Not helpful!
# より良いエラーメッセージ
password = input("Enter password: ")
if len(password) < 8:
print("Error: Password must be at least 8 characters long")
print(f"Your password is only {len(password)} characters")
# さらに良い - 要件を最初にすべて説明する
print("Password requirements:")
print("- At least 8 characters")
print("- Must contain at least one number")
password = input("Enter password: ")
# 長さをチェックする
if len(password) < 8:
print(f"Error: Password too short ({len(password)} characters)")
print("Password must be at least 8 characters")
# 数字を含むかチェックする
elif not any(char.isdigit() for char in password):
print("Error: Password must contain at least one number")
else:
print("Password accepted")any() 関数は、イテラブル(iterable)の要素のうち1つでも真であれば True を返します。ここでは、char.isdigit() が各文字が数字かどうかをチェックし、any() が少なくとも1文字がテストに合格したかどうかを教えてくれます。
26.3) 堅牢な入力処理のために input()、ループ、try/except を組み合わせる
単発の検証チェックは有用ですが、ユーザーの入力ミスが続くケースには対応できません。ユーザーが不正なデータを入力したら、プログラムはもう一度チャンスを与えるべきです。ループ(loop)と検証を組み合わせることで、有効なデータが得られるまで繰り返し尋ねる堅牢な入力処理になります。
26.3.1) 基本的な入力ループパターン
基本パターンは、有効な入力が受け取れるまで続く while ループを使います。
# 有効な年齢が得られるまで尋ね続ける
while True:
age_str = input("Enter your age: ")
try:
age = int(age_str)
if age < 0:
print("Error: Age cannot be negative. Please try again.")
elif age > 150:
print("Error: Age seems unrealistic. Please try again.")
else:
# 有効な入力 - ループを抜ける
break
except ValueError:
print("Error: Please enter a valid number.")
print(f"You are {age} years old")このパターンにはいくつかの重要な要素があります。
while True:は無限ループを作ります- 検証はループの中で行います
- 入力が有効になったら
breakでループを終了します - エラーメッセージでユーザーに再試行を促します
さまざまな入力をこれがどう扱うか見てみましょう。
# Example interaction:
# Enter your age: twenty
# Error: Please enter a valid number.
# Enter your age: -5
# Error: Age cannot be negative. Please try again.
# Enter your age: 25
# You are 25 years old26.3.2) 再利用可能な入力関数を作る
同じ種類の検証済み入力を複数箇所で必要とする場合は、関数を作成します。
def get_positive_integer(prompt):
"""Keep asking until user enters a positive integer."""
while True:
try:
value = int(input(prompt))
if value <= 0:
print("Error: Please enter a positive number.")
else:
return value
except ValueError:
print("Error: Please enter a valid whole number.")
def get_number_in_range(prompt, min_value, max_value):
"""Keep asking until user enters a number in the specified range."""
while True:
try:
value = float(input(prompt))
if value < min_value or value > max_value:
print(f"Error: Please enter a number between {min_value} and {max_value}.")
else:
return value
except ValueError:
print("Error: Please enter a valid number.")
# 関数を使う
quantity = get_positive_integer("Enter quantity: ")
print(f"Quantity: {quantity}")
grade = get_number_in_range("Enter grade (0-100): ", 0, 100)
print(f"Grade: {grade}")
temperature = get_number_in_range("Enter temperature (-50 to 50): ", -50, 50)
print(f"Temperature: {temperature}°C")これらの関数は検証ロジックをカプセル化し、メインコードをよりすっきり読みやすくします。また、プログラム全体で一貫した検証動作を保証します。
26.4) 開発時の不変条件チェックにアサーションを使う
アサーション(assertions)は、開発中にコードの前提が正しいことを確認するための特別なチェックです。バリデーション(ユーザーや外部データからの想定されるエラーを扱う)とは異なり、アサーションはプログラミングミス、つまりコードが正しければ決して起きない状況を捕捉します。
26.4.1) アサーションとは何か、いつ使うのか
アサーション(assertion)とは、コードのある時点で常に真であるべき主張です。もし偽であれば、プログラムのロジックが根本的におかしいということです。
def calculate_average(numbers):
# 関数が正しく呼び出されていれば、これは起きないはず
assert len(numbers) > 0, "numbers list cannot be empty"
return sum(numbers) / len(numbers)
# 正しい使用例
grades = [85, 90, 78]
average = calculate_average(grades)
print(f"Average: {average:.1f}") # Output: Average: 84.3
# 誤った使用例 - アサーションが発火する
empty_list = []
average = calculate_average(empty_list) # AssertionError: numbers list cannot be emptyアサーションが失敗すると、Python は指定したメッセージ付きで AssertionError を送出します。これによりプログラムは即座に停止し、どこで前提が破られたのかを正確に示してくれます。
重要な違い:
- バリデーション(validation)(
ifとraiseを使用): ユーザーや外部データによる想定される問題を扱う - アサーション: 開発中にプログラミングバグを捕捉する
# バリデーション - 想定されるユーザーエラーを扱う
def get_positive_number(prompt):
while True:
try:
value = float(input(prompt))
if value <= 0:
print("Error: Please enter a positive number.")
else:
return value
except ValueError:
print("Error: Please enter a valid number.")
# アサーション - プログラミングミスを捕捉する
def calculate_discount(price, discount_rate):
# プログラムが正しく書かれていれば、これらは破られないはず
assert price >= 0, "price should be non-negative"
assert 0 <= discount_rate <= 1, "discount_rate should be between 0 and 1"
return price * (1 - discount_rate)26.4.2) 関数の事前条件をチェックする
アサーションは、関数の事前条件(preconditions)(関数が実行される前に真である必要がある要件)を検証するのに非常に有効です。
def get_list_element(items, index):
"""Get an element from a list at the specified index."""
# 事前条件
assert isinstance(items, list), "items must be a list"
assert isinstance(index, int), "index must be an integer"
assert 0 <= index < len(items), f"index {index} out of range for list of length {len(items)}"
return items[index]
# 正しい使用例
numbers = [10, 20, 30, 40]
value = get_list_element(numbers, 2)
print(f"Value: {value}") # Output: Value: 30
# プログラミングエラー - 型が間違っている
value = get_list_element("not a list", 0) # AssertionError: items must be a list
# プログラミングエラー - インデックスが不正
value = get_list_element(numbers, 10) # AssertionError: index 10 out of range for list of length 4これらのアサーションは開発中にバグを捕捉するのに役立ちます。誤った型や不正なインデックスをうっかり渡した場合でも、アサーションがすぐに何が問題なのかを教えてくれます。
26.4.3) 関数の事後条件をチェックする
事後条件(postconditions)とは、関数が実行された後に真である必要がある条件です。アサーションを使うことで、関数が有効な結果を返したかどうかを検証できます。
def calculate_percentage(part, whole):
"""Calculate what percentage 'part' is of 'whole'."""
# 事前条件
assert whole > 0, "whole must be positive"
assert part >= 0, "part must be non-negative"
# 割合を計算する
percentage = (part / whole) * 100
# 事後条件 - 結果は有効な割合であるべき
assert 0 <= percentage <= 100, f"percentage {percentage} is outside valid range"
return percentage
# これは正しく動く
percentage = calculate_percentage(25, 100)
print(f"Percentage: {percentage}%") # Output: Percentage: 25.0%
# これは関数内のロジックエラーを明らかにする
# (part <= whole をチェックしていなかった)
percentage = calculate_percentage(150, 100) # AssertionError: percentage 150.0 is outside valid range事後条件のアサーションが関数のバグを捕捉しました。part が whole を超えないことを検証し忘れていたのです。これはまさにアサーションの役割であり、プログラミングミスを捕捉します。
26.4.4) アサーションは無効化できる
アサーションの重要な特徴として、Python を -O(最適化)フラグ付きで実行すると無効化できる点があります。
# このファイル名は test_assertions.py
def divide(a, b):
assert b != 0, "divisor cannot be zero"
return a / b
result = divide(10, 2)
print(f"Result: {result}")
result = divide(10, 0) # AssertionError when assertions are enabled通常実行:
python test_assertions.py
# Output: Result: 5.0
# Then: AssertionError: divisor cannot be zero最適化付きで実行:
python -O test_assertions.py
# Output: Result: 5.0
# Then: ZeroDivisionError: division by zeroこのため、アサーションを外部データのバリデーションに使ってはいけません。誰かが -O でプログラムを実行すると、すべてのアサーションがスキップされます。アサーションは開発とテスト中にプログラミングバグを捕捉するためだけに使ってください。