Исключения C: raise — выбрасываем ошибку сами

Цели урока
  • Самим выбрасывать исключение с помощью raise с понятным сообщением
  • Выбирать подходящий тип ошибки — ValueError, TypeError и другие
  • Понимать разделение работы: функция обнаруживает проблему и выбрасывает ошибку, вызывающий код решает, как её обработать
  • Передавать ошибку дальше изнутри except с помощью одного raise

Что мы изучим?

До сих пор ошибки выбрасывал Python, а мы их ловили. Сегодня научимся выбрасывать их сами — с помощью raise, когда наш код обнаруживает недопустимое состояние.

Зачем это нужно?

Допустим, вы написали функцию withdraw(balance, amount) для снятия денег с банковского счёта. Что она должна делать, когда её просят снять минус 500? Варианты:

  1. Молча выполнить — баланс вырастет на 500. Серьёзный денежный баг.
  2. Напечатать «ошибка» — но тот, кто вызвал функцию, не знает, что она не сработала, и продолжает работать с неверным результатом.
  3. Вернуть специальное значение (например, -1 или None) — вызывающий код должен не забыть его проверить. Если забудет, баг «просочится».
  4. Выбросить ошибку — вызывающий код обязан с ней разобраться, иначе программа остановится с понятным сообщением ровно в проблемном месте.

Вариант 4 — профессиональный способ. В теме ООП мы будем использовать его для проверки данных (Validation) внутри классов, а в темах бэкенда и баз данных сервер FastAPI будет превращать такие ошибки в HTTP-ответ 400 или 404.

Объяснение с основ

raise

raise выбрасывает исключение — точно так же, как это делает Python, когда что-то ломается. С момента raise:

  • Функция немедленно останавливается. Ни одна строка после неё не выполняется, и значение не возвращается.
  • Ошибка «поднимается» к тому, кто вызвал функцию. Если он ловит её через except — отлично. Если нет, она поднимается дальше, и в конце концов программа падает с Traceback.

Правильное разделение: кто обнаруживает и кто обрабатывает

РольКтоЧто делает
Обнаруживает проблемуФункция, которая получает данныеПроверяет и выбрасывает (raise)
Решает, что делатьТот, кто вызвал функциюЛовит (except) и решает: сообщение, ещё одна попытка, значение по умолчанию
Аналогия: склад ресторана

Официант (вызывающий код) просит кладовщика (функцию) 5 помидоров. Помидоров нет. Хороший кладовщик не приносит молча 5 яблок (неверное значение) и не «решает» сам поменять блюдо. Он объявляет (raise): «Помидоров нет!». А официант, который знает клиента, решает: предложить другое блюдо или извиниться. Каждый отвечает за свою область.

Какой тип ошибки выбрасывать?

Используйте встроенные типы по смыслу:

ТипКогда
ValueErrorТип правильный, но значение недопустимо: отрицательный возраст, нулевая сумма, email без @
TypeErrorНеверный тип: получен текст вместо числа
KeyError / LookupErrorИщем то, чего не существует
PermissionErrorНет права на действие

Сообщение должно объяснять, что не так и почему: "amount must be positive, got -500", а не "error".

(В теме ООП, после того как изучим классы, увидим, как создавать собственные типы ошибок, например InsufficientFundsError.)

Синтаксис

raise ValueError("clear message about what is wrong")

# typical pattern: validate at the start of a function
def function_name(value):
    if not is_valid(value):
        raise ValueError("why it is invalid")
    # ... normal work, only with valid data

# the caller decides
try:
    function_name(bad_value)
except ValueError as error:
    print("Problem:", error)

# re-raise: handle partly, then pass the same error up
except ValueError:
    print("logging the problem...")
    raise

Первый пример кода

week-2/withdraw.py:

def withdraw(balance, amount):
    if amount <= 0:
        raise ValueError("amount must be positive, got " + str(amount))
    if amount > balance:
        raise ValueError("insufficient funds: balance " + str(balance) + ", requested " + str(amount))
    return balance - amount

print(withdraw(1000, 300))
print(withdraw(1000, -500))
print("this line never runs")

Вывод:

700
Traceback (most recent call last):
  File "withdraw.py", line 10, in <module>
    print(withdraw(1000, -500))
  File "withdraw.py", line 3, in withdraw
    raise ValueError("amount must be positive, got " + str(amount))
ValueError: amount must be positive, got -500

Разбор кода построчно

СтрокаОбъяснение
if amount <= 0:Проверка данных (Validation): проверить, прежде чем что-либо делать.
raise ValueError("...")Создать объект ValueError с сообщением и выбросить его. Функция останавливается здесь.
if amount > balance:Вторая проверка — для другого бизнес-правила.
return balance - amountСюда мы попадаем, только если все проверки пройдены. Дальше данные безопасны.
withdraw(1000, 300)Проходит проверки и возвращает 700.
withdraw(1000, -500)Выбрасывается ошибка. Никто её не ловит, поэтому программа падает с понятным Traceback. Последняя строка кода не выполняется.

Обратите внимание на качество Traceback: сообщение говорит точно, в чём проблема (amount must be positive) и какое значение её вызвало (-500). Так выглядит хорошая ошибка.

Ещё один пример

Решает вызывающий код. Та же функция с кодом, который ловит и обрабатывает. week-2/atm_safe.py:

def withdraw(balance, amount):
    if amount <= 0:
        raise ValueError("amount must be positive, got " + str(amount))
    if amount > balance:
        raise ValueError("insufficient funds: balance " + str(balance) + ", requested " + str(amount))
    return balance - amount

balance = 1000
requests = [200, -50, 5000, 300]

for amount in requests:
    try:
        balance = withdraw(balance, amount)
    except ValueError as error:
        print("Rejected:", error)
    else:
        print("Withdrew", amount, "- new balance:", balance)

print("Final balance:", balance)

Вывод:

Withdrew 200 - new balance: 800
Rejected: amount must be positive, got -50
Rejected: insufficient funds: balance 800, requested 5000
Withdrew 300 - new balance: 500
Final balance: 500

Разбор:

  • withdraw ничего не печатает и не знает, что будет дальше. Она только обнаруживает и выбрасывает.
  • Цикл (вызывающий код) решает: запрос отклоняется с сообщением, и цикл переходит к следующему запросу.
  • Когда ошибка выброшена, присваивание balance = withdraw(...) не выполняется, поэтому баланс остаётся прежним. Именно этого мы и хотели.
  • Завтра ту же withdraw можно использовать на сайте, в приложении или на сервере, и каждый из них обработает ошибку по-своему (сообщение на экране, HTTP-ответ 400…).

raise без ничего после — передаём дальше

Иногда при ошибке нужно сделать что-то (например, записать её), но не «заставить её исчезнуть»:

try:
    balance = withdraw(balance, 5000)
except ValueError:
    print("Audit: a failed withdrawal attempt was recorded")
    raise

Одиночный raise внутри except снова выбрасывает ту же самую ошибку. Она продолжает подниматься к тому, кто выше.

Типичная ошибка

Ошибка 1: print вместо raise.

def withdraw(balance, amount):
    if amount > balance:
        print("Not enough money!")
    return balance - amount

Функция печатает сообщение, но продолжает и возвращает отрицательный баланс! Вызывающий код не знает, что что-то пошло не так. К тому же печать «застревает» на экране и ничего не значит на сервере или в приложении.

Ошибка 2: бесполезное сообщение.

raise ValueError("error")
raise ValueError("bad input")

Какой ввод? Какое было значение? Что ожидается? Хорошее сообщение = каково условие + что получено: "age must be between 0 and 120, got 150".

Ошибка 3: выбрасывать самый общий тип.

raise Exception("amount must be positive")

Exception — родитель почти всех ошибок. Тому, кто хочет поймать только эту проблему, понадобится except Exception, который ловит и посторонние баги. Выбирайте конкретный тип (ValueError).

Ошибка 4: поймать и выбросить, потеряв контекст.

except ValueError:
    raise ValueError("something failed")

Исходное сообщение (с деталями) потеряно. Если хотите передать ошибку дальше, одиночный raise сохраняет исходную ошибку.

Практика

Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.

Упражнение 1: Допустимый возраст

Напишите функцию set_age(age), которая возвращает возраст, если он от 0 до 120, а иначе выбрасывает ValueError с сообщением, показанным в ожидаемом выводе (включая полученный возраст). Затем напишите цикл, который пробует возрасты [25, -3, 150]: для допустимого возраста печатает ok: и возраст, а при ошибке — error: и сообщение.

Ожидаемый вывод:

ok: 25
error: age must be between 0 and 120, got -3
error: age must be between 0 and 120, got 150
Показать решение
MIN_AGE = 0
MAX_AGE = 120


def set_age(age):
    if age < MIN_AGE or age > MAX_AGE:
        raise ValueError("age must be between 0 and 120, got " + str(age))
    return age


for age in [25, -3, 150]:
    try:
        print("ok:", set_age(age))
    except ValueError as error:
        print("error:", error)
ok: 25
error: age must be between 0 and 120, got -3
error: age must be between 0 and 120, got 150

Функция только обнаруживает проблему и выбрасывает ошибку. Вызывающий её код — цикл — решает, что делать.

Упражнение 2: Имя пользователя

Напишите функцию validate_username(name), которая выбрасывает ValueError:

  • если имя короче 3 символов — с сообщением too short;
  • если в нём есть пробел — с сообщением no spaces allowed.

Если имя допустимо — возвращает его.

for name in ["dana", "al", "big boss"]:
    try:
        print("valid:", validate_username(name))
    except ValueError as error:
        print(name, "->", error)

Ожидаемый вывод:

valid: dana
al -> too short
big boss -> no spaces allowed
Показать решение
MIN_USERNAME_LENGTH = 3


def validate_username(name):
    if len(name) < MIN_USERNAME_LENGTH:
        raise ValueError("too short")
    if " " in name:
        raise ValueError("no spaces allowed")
    return name


for name in ["dana", "al", "big boss"]:
    try:
        print("valid:", validate_username(name))
    except ValueError as error:
        print(name, "->", error)
valid: dana
al -> too short
big boss -> no spaces allowed

Упражнение 3: Решает вызывающий код

Следующая функция выбрасывает ошибку для отрицательной цены:

def apply_discount(price, percent):
    if price < 0:
        raise ValueError("price cannot be negative")
    return price - price * percent / 100

Напишите две функции, которые используют её двумя разными способами:

  1. report_price(price) — печатает цену после скидки 10% или invalid price, если была ошибка.
  2. price_or_zero(price) — возвращает цену после скидки 10% или 0, если была ошибка.
report_price(200)
report_price(-5)
print(price_or_zero(50))
print(price_or_zero(-5))

Ожидаемый вывод:

180.0
invalid price
45.0
0
Показать решение
def apply_discount(price, percent):
    if price < 0:
        raise ValueError("price cannot be negative")
    return price - price * percent / 100


def report_price(price):
    try:
        print(apply_discount(price, 10))
    except ValueError:
        print("invalid price")


def price_or_zero(price):
    try:
        return apply_discount(price, 10)
    except ValueError:
        return 0


report_price(200)
report_price(-5)
print(price_or_zero(50))
print(price_or_zero(-5))
180.0
invalid price
45.0
0

Одна и та же функция, два разных способа обработки. Поэтому apply_discount выбрасывает ошибку, а не печатает сама: она не знает, что нужно вызывающему коду.

Упражнение 4: return None или raise

Две версии одной функции:

# A                                   # B
def withdraw(balance, amount):        def withdraw(balance, amount):
    if amount > balance:                  if amount > balance:
        print("Not enough money!")            raise ValueError("not enough money")
        return None                       return balance - amount
    return balance - amount

Кто-то написал: balance = withdraw(100, 500). Что произойдёт в каждой версии и почему B лучше?

Показать решение
  • A: печатается «Not enough money!», а balance получает None. Программа продолжает работать с балансом None и упадёт позже где-то в другом месте — далеко от настоящей проблемы.
  • B: ValueError выбрасывается ровно в месте проблемы. Если вызывающий код его не обработал, Traceback указывает прямо на проблемное снятие. Если обработал — он сам решает, что показать.
  • Кроме того, B не печатает сама: возможно, вызывающий код — это сервер, которому нужно вернуть сообщение, а не печатать на экран.

Упражнение 5: Преобразование оценки

Напишите функцию parse_grade(text), которая преобразует текст в оценку. Она выбрасывает ValueError, если текст не число (это уже происходит в int), или если оценка вне диапазона от 0 до 100 — с сообщением, показанным в ожидаемом выводе (включая полученную оценку).

for text in ["87", "abc", "120"]:
    try:
        print("grade:", parse_grade(text))
    except ValueError as error:
        print("rejected:", error)

Ожидаемый вывод:

grade: 87
rejected: invalid literal for int() with base 10: 'abc'
rejected: grade must be 0-100, got 120
Показать решение
def parse_grade(text):
    grade = int(text)
    if grade < 0 or grade > 100:
        raise ValueError("grade must be 0-100, got " + str(grade))
    return grade


for text in ["87", "abc", "120"]:
    try:
        print("grade:", parse_grade(text))
    except ValueError as error:
        print("rejected:", error)
grade: 87
rejected: invalid literal for int() with base 10: 'abc'
rejected: grade must be 0-100, got 120

parse_grade не ловит ошибку из int — она позволяет ей пройти дальше к вызывающему коду, точно так же, как ошибке, которую выбрасывает сама.

Итоги

  • raise SomeError("message") выбрасывает ошибку. Функция немедленно останавливается, и значение не возвращается.
  • Функция обнаруживает и выбрасывает. Вызывающий код ловит и решает.
  • ValueError для недопустимого значения, TypeError для неверного типа. Не общий Exception.
  • Хорошее сообщение = каково правило + что получено.
  • Одиночный raise внутри except передаёт ту же ошибку дальше.
  • Выбросить ошибку лучше, чем напечатать «ошибка» или вернуть специальное значение, потому что ошибку нельзя «забыть проверить».

Официальная документация