Исключения A: зачем нужна обработка ошибок, try и except

Цели урока
  • Объяснить, что такое исключение (Exception) и что происходит с программой, если его не обработать
  • Писать блок try / except, который ловит ошибку и обрабатывает её
  • Обрабатывать несколько видов исключений по отдельности и получать детали ошибки с помощью as
  • Понимать, почему «пустой» except, который ловит всё, — опасная привычка

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

  • Что такое исключение (Exception).
  • Почему настоящая программа должна обрабатывать ошибки, а не падать.
  • Синтаксис try / except.
  • Разную обработку для разных видов ошибок.

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

Представьте маленький сервер-калькулятор: один клиент отправляет "hello" вместо числа, и весь сервер падает. Все клиенты, которые придут после него, получают «Connection refused».

В реальном мире:

  • Пользователь вводит «abc» в поле возраста. Приложение не должно закрываться.
  • Файл, который программа пытается прочитать, удалён. Она должна сообщить об этом, а не упасть.
  • Интернет пропал посреди запроса. Стоит попробовать снова или показать сообщение.

Ошибки будут происходить: плохой ввод, отсутствующие файлы, нестабильная сеть. Вопрос лишь в том, что программа делает, когда они происходят.

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

Что такое исключение?

Исключение (Exception) — объект, который Python «выбрасывает» (Raise), когда возникает Runtime Error. Все ошибки, которые мы видели вчера (ValueError, ZeroDivisionError, TypeError…), — это исключения.

Когда выбрасывается исключение:

  1. Python немедленно останавливает текущую строку.
  2. Он ищет того, кто «поймает» (Catch) ошибку.
  3. Если никто не ловит, программа падает и печатается Traceback.

try / except — способ поймать.

Аналогия: страховочная сетка в цирке

Канатоходец (ваш код) может упасть (исключение). Без сетки падение — конец представления (падение программы). try = «представление идёт над сеткой». except = сетка, которая ловит падение, а потом вы решаете, что делать: попробовать снова, сообщить зрителям или перейти к следующему номеру.

Как это работает

  • try: — «попробуй выполнить этот блок».
  • except SomeError: — «если внутри try выброшено SomeError, перейди сюда и выполни этот блок вместо падения».
  • Если ошибки не было, блок except пропускается.
  • После try/except программа продолжает как обычно.

Синтаксис

try:
    # code that might fail
except ErrorType:
    # what to do if ErrorType happened

# several error types, each handled differently
try:
    ...
except ValueError:
    ...
except ZeroDivisionError:
    ...

# get the error object itself (its message)
except ValueError as error:
    print(error)

# same handling for several types
except (ValueError, TypeError):
    ...

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

week-2/safe_age.py:

text = input("Enter your age: ")

try:
    age = int(text)
    print("Next year you will be", age + 1)
except ValueError:
    print("That is not a whole number:", text)

print("Program continues normally.")

Запуск 1 (корректный ввод):

Enter your age: 30
Next year you will be 31
Program continues normally.

Запуск 2 (некорректный ввод):

Enter your age: abc
That is not a whole number: abc
Program continues normally.

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

СтрокаЗапуск 1 (30)Запуск 2 (abc)
text = input(...)text = "30"text = "abc"
try:Входит в блокВходит в блок
age = int(text)Успешно: age = 30Выбрасывается ValueError, и Python сразу переходит к except
print("Next year...")ПечатаетсяПропускается. Мы до неё не доходим
except ValueError:Пропускается (ошибки нет)Поймано!
print("That is not...")Не выполняетсяПечатается
print("Program continues...")ПечатаетсяПечатается, и программа не упала

Важный момент: как только выброшена ошибка, остальные строки в try не выполняются. Поэтому внутрь try кладут только то, что зависит от успеха.

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

Надёжный калькулятор деления — с разной обработкой для каждого вида ошибки и циклом, который спрашивает снова. week-2/safe_divide.py:

def ask_number(prompt):
    while True:
        text = input(prompt)
        try:
            return float(text)
        except ValueError:
            print("Please type a number, for example 12 or 3.5")

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        print("Cannot divide by zero - returning 0")
        return 0

x = ask_number("First number: ")
y = ask_number("Second number: ")
print("Result:", divide(x, y))

Пример запуска:

First number: ten
Please type a number, for example 12 or 3.5
First number: 10
Second number: 0
Cannot divide by zero - returning 0
Result: 0

Разбор:

  • return float(text) внутри try — если преобразование удалось, функция немедленно возвращает значение и заодно выходит из цикла. Если не удалось, except печатает сообщение, и цикл спрашивает снова. Так вы «вынуждаете» пользователя ввести корректное значение.
  • Каждая функция ловит ошибку, которая относится к ней: ask_number ловит ValueError (ввод), divide ловит ZeroDivisionError (вычисление).
  • Открытый вопрос: должна ли divide возвращать 0 при делении на ноль? Математически это неверный ответ! Во многих случаях лучше не ловить ошибку здесь и дать решить вызывающему коду. Поговорим об этом в уроке про raise.

as error — получение деталей ошибки

try:
    number = int("12.5")
except ValueError as error:
    print("Conversion failed:", error)
Conversion failed: invalid literal for int() with base 10: '12.5'

error — объект ошибки. Если его напечатать, увидите сообщение, которое появилось бы в последней строке Traceback.

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

Ошибка 1: пустой except, который ловит всё.

try:
    age = int(input("Age: "))
    print("Double:", age * 2)
    reslt = age / 0
except:
    print("Something went wrong")

Здесь три разные проблемы (ввод, деление на ноль и даже пропущенная буква в имени переменной), и все они спрятаны под «Something went wrong». Отладить это невозможно. И хуже всего: пустой except: ловит и KeyboardInterrupt, поэтому даже Ctrl + C не остановит программу, застрявшую в таком цикле!

Правило: ловите только конкретные ошибки, которые вы умеете обрабатывать. Все остальные идут дальше вверх и приводят к падению — и это хорошо, потому что так обнаруживаются баги.

Ошибка 2: огромный try.

try:
    # 40 lines of code...
except ValueError:
    print("Invalid value")

Какая из 40 строк упала? Неизвестно. Кладите в try только строки, которые могут упасть с ловимой ошибкой, — как можно меньше.

Ошибка 3: молча «проглотить» ошибку.

try:
    price = float(text)
except ValueError:
    pass

pass означает «ничего не делать». Ошибка исчезает, а price может вообще не существовать, что вызовет непонятный NameError несколькими строками ниже. Всегда делайте что-нибудь: сообщение, объяснённое значение по умолчанию или запись в лог.

Практика

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

Упражнение 1: Безопасное преобразование в число

Напишите функцию to_number(text), которая возвращает текст как целое число, а если текст не число — возвращает None, и программа при этом не падает.

print(to_number("42"))
print(to_number("hello"))
print(to_number("7"))

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

42
None
7
Показать решение
def to_number(text):
    try:
        return int(text)
    except ValueError:
        return None


print(to_number("42"))
print(to_number("hello"))
print(to_number("7"))
42
None
7

int("hello") выбрасывает ValueError. Без try программа упала бы. С except ValueError мы решаем, что произойдёт вместо этого.

Упражнение 2: Разная обработка для каждого вида ошибки

Напишите функцию safe_divide(a_text, b_text), которая получает два текста, преобразует их в числа и делит. Она возвращает:

  • результат деления, если всё корректно;
  • "numbers only", если один из текстов не число;
  • "cannot divide by zero", если делитель равен 0.
print(safe_divide("10", "4"))
print(safe_divide("10", "0"))
print(safe_divide("ten", "2"))

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

2.5
cannot divide by zero
numbers only
Показать решение
def safe_divide(a_text, b_text):
    try:
        return int(a_text) / int(b_text)
    except ValueError:
        return "numbers only"
    except ZeroDivisionError:
        return "cannot divide by zero"


print(safe_divide("10", "4"))
print(safe_divide("10", "0"))
print(safe_divide("ten", "2"))
2.5
cannot divide by zero
numbers only

У каждого вида ошибки свой except с подходящей обработкой. Python проверяет блоки except по порядку и входит в первый подходящий.

Упражнение 3: Показываем сообщение об ошибке

Следующий код падает с ValueError. Оберните его в try, поймайте ошибку через except ValueError as error и напечатайте текст Could not convert:, а за ним сообщение, которое Python сохранил в error:

number = int("12abc")

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

Could not convert: invalid literal for int() with base 10: '12abc'
Показать решение
try:
    number = int("12abc")
except ValueError as error:
    print("Could not convert:", error)
Could not convert: invalid literal for int() with base 10: '12abc'

as error сохраняет объект ошибки в переменной. Когда вы его печатаете, получаете сообщение, которое создал Python.

Упражнение 4: Выбор из списка

У вас есть список имён. Напишите функцию pick(names, position_text), которая получает позицию в виде текста (начиная с 1) и возвращает имя на этой позиции. Она возвращает "not a number" для ввода, который не является числом, и "no such position" для несуществующей позиции.

names = ["Dana", "Avi", "Noa"]
print(pick(names, "2"))
print(pick(names, "abc"))
print(pick(names, "9"))

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

Avi
not a number
no such position
Показать решение
def pick(names, position_text):
    try:
        position = int(position_text)
        return names[position - 1]
    except ValueError:
        return "not a number"
    except IndexError:
        return "no such position"


names = ["Dana", "Avi", "Noa"]
print(pick(names, "2"))
print(pick(names, "abc"))
print(pick(names, "9"))
Avi
not a number
no such position

Две разные ошибки в двух местах: int(...) может выбросить ValueError, а обращение к списку — IndexError.

Упражнение 5: except, который ловит всё

def read_age(text):
    try:
        return int(text)
    except:
        return 0
  1. Почему except: без типа ошибки опасен?
  2. Приведите пример настоящего бага, который скрыл бы этот код.
  3. Исправьте код.
Показать решение
  1. Пустой except: ловит любую ошибку — включая те, о которых мы не подумали, — и скрывает их. Код продолжает работать, будто всё в порядке, с неверным значением.
  2. Если кто-то по ошибке передаст None вместо текста, будет выброшен TypeError — настоящий баг в коде, который вызвал функцию. Вместо того чтобы увидеть его, мы молча получим возраст 0. (Даже Ctrl + C ловится пустым except:.)
  3. Ловить только ту ошибку, которую мы ожидаем:
def read_age(text):
    try:
        return int(text)
    except ValueError:
        return 0


print(read_age("30"))
print(read_age("thirty"))
30
0

Итоги

  • Исключение (Exception) = ошибка, которую Python выбрасывает во время выполнения. Если её никто не ловит, программа падает.
  • try = «попробуй». except SomeError = «если произошла эта ошибка, сделай это вместо падения».
  • Как только выброшена ошибка, остаток try пропускается.
  • except SomeError as error даёт доступ к сообщению об ошибке.
  • Ловите только конкретные ошибки, с маленьким try. Никаких пустых except: и никаких pass, которые проглатывают ошибки.

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