Зарегистрируйтесь, чтобы продолжить обучение

Исключения Python: Введение в ООП

Отдельная большая тема в программировании – обработка ошибок. До сих пор нам удавалось избегать её, но в реальном мире, где приложения содержат тысячи, десятки и сотни тысяч (а то и все миллионы) строк кода, обработка ошибок влияет на многое: простоту модификации и расширения, адекватное поведение программы для пользователя в разных ситуациях.

В этом уроке мы рассмотрим механизм исключений. Но перед тем, как изучать новые конструкции, поговорим про ошибки вообще.

В Python у строк есть метод, который называется text.index(x). Он ищет подстроку x внутри текста text и возвращает индекс начала этой подстроки в тексте. Что произойдёт, если подстрока не была найдена? Является ли это поведение ошибкой? Нет. Это штатное поведение функции. От того, что подстрока не была найдена, ничего страшного не случилось. Представьте себе любой редактор текста и механизм поиска внутри него. Ситуация, когда ничего не было найдено, возникает постоянно, и это не ломает работу программы.

Кстати, посмотрите в документацию этой функции, каким образом она говорит о том, что подстрока не была найдена?

Другая ситуация. В тех же редакторах есть функция "открыть файл". Представьте, что во время открытия файла что-то пошло не так, например, его удалили. А это ошибка или нет? Да, в этой ситуации произошла ошибка, но это не ошибка программирования. Подобная ошибка может возникнуть всегда, независимо от желания программиста. Он не может избежать её появления. Единственное, что он может — правильно реализовать её обработку.

Ещё один интересный вопрос, насколько это критичная ошибка? Должна ли она приводить к остановке всего приложения или нет? В плохо написанных приложениях, там где неправильно реализована обработка ошибок, такая ситуация приведёт к краху всего приложения, и оно завершится. В хорошо написанном приложении не произойдёт ничего страшного. Пользователь увидит предупреждение о том, что файл не читается, и сможет выбрать дальнейшие действия, например, попытаться прочитать его снова или выполнить другое действие.

Сказанное выше имеет очень серьёзные следствия. Одна и та же ситуация на разных уровнях может как являться ошибкой, так и быть вполне штатной ситуацией. Например, если задача функции читать файл, а она не смогла этого сделать, то с точки зрения этой функции произошла ошибка. Должна ли она приводить к остановке всего приложения? Как мы выяснили выше – не должна. Принимать решение о том, насколько критична данная ситуация, может приложение, которое использует эту функцию, но не сама функция.

Коды возврата

В языках, появившихся до 1990 года (примерно), обработка ошибок выполнялась через механизм возврата функцией специального значения. Например, в Си, если функция не может выполнить свою задачу, то она должна вернуть специальное значение, либо NULL, либо отрицательное число. Значение этого числа говорит о том, какая ошибка произошла. Например:

int write_log()
{
    int ret = 0; // return value 0 if success
    FILE *f = fopen("logfile.txt", "w+");

    // Проверяем, получилось ли открыть файл
    if (!f)
        return -1;

    // Проверяем, что не достигли конца файла
    if (fputs("hello logfile!", f) != EOF) {
        // continue using the file resource
    } else {
        // Файл закончился
        ret = -2;
    }

    // Не получилось закрыть файл
    if (fclose(f) == EOF)
        ret = -3;

    return ret;
}

Обратите внимание на условные конструкции и постоянное присваивание переменной ret. Фактически каждая потенциально опасная операция должна проверяться на успешность выполнения. Если что-то пошло не так, то функция возвращает специальный код.

И вот тут начинаются проблемы. Как показывает жизнь, в большинстве ситуаций ошибка обрабатывается не там, где она возникла, и даже не уровнем выше. Предположим, что есть функция A, которая вызывает код, потенциально приводящий к ошибке, и она его должна уметь правильно обработать и сообщить пользователю о проблеме. При этом сама ошибка происходит внутри функции E, которая вызывается внутри A не напрямую, а через цепочку функций: A => B => C => D => E. Подумайте, к чему приводит такая схема? Все функции в этой цепочке, даже несмотря на то, что они не обрабатывают ошибку, обязаны знать про неё, отлавливать её и так же возвращать наружу код этой ошибки. В итоге кода, который занимается ошибками, становится так много, что за ним теряется код, выполняющий исходную задачу.

Стоит сказать, что существуют схемы обработки ошибок, которые не обладают такими недостатками, но работают по принципу возврата. Например, монада Either.

Исключения

Именно на этом фоне и возник механизм исключений. Его главная цель — передать ошибку из того места, где она возникла, в то место, где её можно обработать, минуя все промежуточные уровни. Другими словами, механизм исключений раскручивает стек вызовов самостоятельно.

С исключениями нужно запомнить две вещи: код, в котором произошла ошибка, генерирует исключение, а код, в котором ошибка обрабатывается – его обрабатывает.

def read_file(filepath):
    """
    Функция, которая может сгенерировать исключение.
    """
    if not is_file_readable(filepath):
        raise Exception(f"'{filepath}' is not readable")
    # ...

def run(filepath):
    """
    Функция, которая обрабатывает исключения.
    """
    try:
        # Пытаемся выполнить потенциально опасный код
        read_file(filepath)
    except Exception as e:
        print(e)
    # если тут будет код, он продолжит выполняться

Сами исключения – это объекты Exception. Эти объекты содержат внутри себя сообщение, переданное в конструктор, трассировку стека и другие полезные данные.

Самостоятельно исключение генерируется с помощью ключевого слова raise:

e = Exception('Тут любой текст')
raise e # Исключение можно создать отдельно, а можно сразу же там, где используется raise

raise прерывает дальнейшее выполнение кода. В этом смысле оно подобно return, но в отличие от него, прерывает выполнение не только текущей функции, но и всего кода, вплоть до ближайшего в стеке вызовов блока except.

Конструкция try/except - это специальная инструкция из двух блоков, которая позволяет перехватить все исключения и их обработать.

Первый блок формируется после try. Любые исключения, которые будут сгенерированы кодом, расположенным внутри этого блока, будут перехвачены и переданы во второй блок except. Если ошибки не было, то этот блок пропускается.

try:
  # здесь какое-то действие, которое может сгенерировать ошибку
except Exception as e:
  # здесь код, который мы хотим выполнить, отловив ошибку

В добавок к блокам try/except существует и третья инструкция - finally. Код в блоке finally выполнится всегда, даже если в except есть return или вовсе нет обработки ошибки. Обычно в этот блок принято добавлять операции по очистке ресурсов, закрытию файлов и соединений, записи данных в лог и прочие действия называемые "финализацией".

try:
    value = 1/0
except Exception:
    return 'oops!'
finally:
    # блок finally все равно выполнится несмотря на возврат в except
    print('Do some cleanup')

Иерархия исключений

В практике с Python вы уже неоднократно встречались с различными исключениями: IndexError и KeyError - не найден индекс или ключ, IOErrror - ошибка ввода/вывода, да хотя бы ZeroDivisionError - ошибка при делении на ноль. Все они являются подклассами базового класса исключений BaseException. В действительности структура наследования, или как говорят "иерархия исключений", представляет собой дерево, корнем которого является BaseException, стволом — Exception, а дальше происходит ветвление на виды исключений, а затем — на конкретные исключения.

BaseException
 ├── BaseExceptionGroup
 ├── GeneratorExit
 ├── KeyboardInterrupt
 ├── SystemExit
 └── Exception
      ├── ArithmeticError
      │    ├── FloatingPointError
      │    ├── OverflowError
      │    └── ZeroDivisionError
      ├── LookupError
      │    ├── IndexError
      │    └── KeyError
      ├── OSError
      │    ├── BlockingIOError
      │    ├── ChildProcessError
...

Перехватывая исключение мы также перехватываем и всех его потомков. Так мы можем обрабатывать ошибки гранулировано, отлавливая конкретные исключения по одному, IndexError, так и обрабатывать группы ошибок перехватывая исключения выше по иерархии - LookupError.

Важно помнить, что если у вас указано несколько веток except, то первыми нужно указывать наиболее конкретные ветки. Иначе вы можете оказаться в ситуации вроде этой:

try:
    user = users[input('May I have your name? ')]
except Exception:
    sys.exit(1)  # молча завершаем программу
except (KeyError, IndexError):
    print('No users with such name found!')

Здесь ветка except Exception: отлавливает вообще все исключения, ведь любое конкретное исключение потомок Exception. А вторая ветка except не имеет шанса хоть раз быть выполненной.

Заметьте, что во второй ветке мы указали кортеж, содержащий несколько классов исключений. Таким образом можно указать обработчик, который будет перехватывать несколько видов исключений.

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

# пример из фреймворка Celery

# кастомные исключения принято делать наследниками базового Exception с пустым телом
class MyException(Exception):
    pass

@app.task(autoretry_for=(MyException,))
def sum_numbers(num1, num2):
    return sum_(num1, num2)


def sum(num1, num2):
    if num1 == 42:
        raise MyException('Try another number!')
    return num1 + num2

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

Получение экземпляра исключения и генерация уже пойманного

Часто вы хотите получить доступ к сообщению исключения или каким-то дополнительным данным: более специфичные для предметной области исключения могут иметь специальные атрибуты, полезные при отладке. Для этого нужно указать в ветке except имя переменной, которая получит ссылку на экземпляр исключения:

try:
    ...
except (SQLSelectError, SQLInsertError) as e:
    print(f"Query execution error: '{e.query}'")
except DBConnectionError as e:
    print(f"Can't connect to DB: '{e.status}'")
...

Но что делать, если вы перехватили исключение, сделали некие необходимые действия, а затем решили "пробросить" исключение выше. В таком случае мы можем написать raise from e - так мы сгенерируем новое исключение, пробросив весь стектрейс ошибки.

Антипаттерны при перехвате исключений

Перехватывать исключения можно неправильно. Частая ошибка - перехват слишком общих исключений, как except Exception или просто except. Чем же плох такой способ поймать сразу все исключения? Тем, что можно случайно поймать то, что ловить совсем даже не нужно, например ошибку "переменная не объявлена":

try:
    bla-bla
except:
    pass  # теперь мы не увидим, что переменная "bla-bla" не была объявлена

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

Единственный случай, в котором допустимо перехватывать Exception, это случай, когда в конце обработчика исключение генерируется заново. Таким способом мы можем записать в лог нашу ошибку, и затем пробросить их выше, чтобы у вызывающей стороны была возможность отреагировать на ошибку.

Еще один антипаттерн: огромные try-блоки. Практически всегда лучше перехватывать конкретные исключения в небольших участках кода, а в других участках не перехватывать ничего. Так вы не окажетесь в режиме "защитного программирования", когда все исключения всегда ловятся максимально рано, из-за чего код становится очень сложно читать.

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

try:
    resp = requests.get('http://example.com')
    resp.raise_for_status()
except:
    ...

Дополнительные материалы

  1. Исключения
  2. Иерархия исключений

Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff
Рекомендуемые программы
профессия
от 25 000 ₸ в месяц
Разработка веб-приложений на Django
10 месяцев
с нуля
Старт 6 февраля

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»