26. Техники защитного программирования с использованием исключений и валидации
Защитное программирование означает написание кода, который заранее предусматривает проблемы до того, как они возникнут. Вместо того чтобы предполагать, что всё будет работать идеально, защитный код валидирует входные данные, корректно обрабатывает ошибки и проверяет предположения. Такой подход создаёт программы, которые надёжнее, их проще отлаживать, и они реже неожиданно падают.
В предыдущих главах мы научились обрабатывать исключения, когда они возникают. Теперь мы научимся предотвращать многие ошибки ещё до того, как они произойдут, и выявлять проблемы на ранней стадии, когда они всё же случаются.
26.1) Валидация аргументов функции
Функции часто получают данные из других частей вашей программы или от пользователей. Если функция получает некорректные данные, она может выдать неправильные результаты, завершиться с непонятной ошибкой или вызвать проблемы в других местах программы. Валидация аргументов означает проверку того, что аргументы функции соответствуют вашим требованиям, до того как вы начнёте их использовать.
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: Деление на ноль
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 100Функция isinstance() проверяет, является ли объект экземпляром указанного типа или типов. Мы передаём кортеж (int, float), чтобы принимать либо целые числа, либо числа с плавающей точкой, так как оба являются корректными числовыми типами для цен.
Когда валидировать типы: философия Python — «утиная типизация»: если объект ведёт себя так, как вам нужно, используйте его. Валидация типов наиболее полезна, когда:
- вы пишете функцию, которой будут пользоваться другие
- ошибки типа будут приводить к запутанным сбоям позже
- функция является частью публичного API или библиотеки
26.1.4) Валидация аргументов-коллекций
Когда функции принимают списки, словари или другие коллекции, валидируйте и саму коллекцию, и её содержимое:
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")
# Валидируем каждую оценку в коллекции
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
# Некорректный тип оценки
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
# Некорректное значение оценки
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")Этот шаблон состоит из трёх частей:
- Получить ввод как строку
- Попробовать преобразовать его в нужный тип
- Проверить, является ли преобразованное значение корректным
Посмотрим, как это работает с разными вариантами ввода:
# Корректный ввод
# User enters: 25
# Output: You are 25 years old
# Некорректный тип
# User enters: twenty-five
# Output: Error: Please enter a valid number
# Некорректное значение (отрицательное)
# User enters: -5
# Output: Error: Age cannot be negative
# Некорректное значение (нереалистичное)
# 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 (простая проверка)
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...")Проверка email здесь намеренно простая — она лишь проверяет базовую структуру. Реальная валидация email намного сложнее и обычно использует регулярные выражения (о которых мы узнаем в главе 39).
26.2.3) Предоставление полезных сообщений об ошибках
Хорошие сообщения об ошибках точно говорят пользователям, что пошло не так и как это исправить:
# Плохое сообщение об ошибке
password = input("Enter password: ")
if len(password) < 8:
print("Error: Invalid password") # Не помогает!
# Более хорошее сообщение об ошибке
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() возвращает True, если хотя бы один элемент в итерируемом объекте истинный. Здесь char.isdigit() проверяет, является ли каждый символ цифрой, а any() говорит нам, прошёл ли проверку хотя бы один символ.
26.3) Комбинирование input(), циклов и try/except для надёжной обработки ввода
Одиночные проверки валидации полезны, но они не справляются с постоянными ошибками пользователя. Если пользователь вводит некорректные данные, ваша программа должна дать ему ещё одну попытку. Комбинирование циклов с валидацией создаёт надёжную обработку ввода, которая продолжает спрашивать, пока не получит корректные данные.
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.")
# Using the functions
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) Использование утверждений для проверок инвариантов на этапе разработки
Утверждения — это особый вид проверки, используемый во время разработки, чтобы убедиться, что предположения вашего кода верны. В отличие от валидации (которая обрабатывает ожидаемые ошибки от пользователей или внешних данных), утверждения выявляют ошибки программирования — ситуации, которые никогда не должны происходить, если ваш код написан правильно.
26.4.1) Что такое утверждения и когда их использовать
Утверждение — это выражение, которое в определённой точке кода всегда должно быть истинным. Если оно ложно, значит, в логике программы есть фундаментальная ошибка:
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 с вашим сообщением. Это немедленно останавливает программу и показывает вам, где именно было нарушено ваше предположение.
Ключевое различие:
- Валидация (с использованием
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) Проверка предусловий функции
Утверждения отлично подходят для проверки предусловий функции (требований, которые должны быть истинны до выполнения функции):
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) Проверка постусловий функции
Постусловия — это условия, которые должны быть истинны после выполнения функции. Утверждения могут проверять, что функция произвела корректные результаты:
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 (optimize):
# Этот файл называется 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, все утверждения будут пропущены. Используйте утверждения только для выявления ошибок программирования во время разработки и тестирования.