понедельник, 17 декабря 2012 г.

Исключения в Питоне

Поговорим об исключениях.

Всё нижеизложенное относится к Python 3.3, хотя отчасти справедливо и для более ранних версий.

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

Рассмотрим простейший пример: открытие файла. Если всё нормально — open(filename, 'r') возвращает объект этого самого файла, с которым можно делать всякие полезные вещи: читать из него данные и т.д.

Если файл не может быть открыт — выбрасывается исключение:

try:
    f = open(filename, 'r')
    try:
        print(f.read())
    finally:
        f.close()
except OSError as ex:
    print("Cannot process file", filename, ": Error is", ex)

Открываем файл и печатаем его содержимое.

Обратите внимание: файл нужно не только открыть но и закрыть после использования. Исключение может выбросить open (например, если файла нет на диске или нет прав на его чтение).

Если файл открыт — читаем его через f.read(). Этот вызов тоже может выбросить исключение, но файл закрывать всё равно нужно. Поэтому необходим блок finally: f.close() должен быть вызван даже если f.read() сломался. В этом месте удобней было бы воспользоваться конструкцией with но мы же сейчас говорим об исключениях а не о контекстных менеджерах, верно?

Исключения из обоих мест попадут в except OSError, где можно будет что-то сделать с ошибкой.

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

Введение закончено. Теперь сконцентрируемся на том что происходит в except.

Типы исключений

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

BaseException
 +-- SystemExit
 +-- KeyboardInterrupt
 +-- GeneratorExit
 +-- Exception
      +-- StopIteration
      +-- AssertionError
      +-- AttributeError
      +-- BufferError

Самый базовый класс — BaseException. Он и его простые потомки (SystemExit, KeyboardInterrupt, GeneratorExit) не предназначены для перехвата обыкновенным программистом — только Питон и редкие библиотеки должны работать с этими типами. Нарушение правила ведет, например, к тому что программу невозможно корректно завершить — что совсем не хорошо.

Также не нужно перехватывать все исключения:

try:
    ...
except:
    ...

работает как

try:
    ...
except BaseException:
    ...

Всё, что может быть нужно программисту — это Exception и унаследованные от него классы.

Вообще-то лучше ловить как можно более конкретные классы исключений. Например, в случае с файлом это OSError или даже может быть FileNotFoundError. Таким образом мы не перехватим AttributeError или ValueError, которые в этом примере означали бы ошибку или опечатку программиста.

Кстати, обратите внимание: StopIteration порожден от Exception а GeneratorExit от BaseException. Подробности, почему сделано именно так, можно найти в PEP 342.

Цепочки исключений

Прочитав предыдущую главку все прониклись необходимостью указывать правильный класс исключений и пообещали никогда не использовать BaseException.

Идем дальше. Следующий пример:

try:
    user = get_user_from_db(login)
except DBError as ex:
    raise UserNotFoundError(login) from ex

Получаем пользователя из базы данных чтобы что-то потом с ним сделать. get_user_from_db может выбросить ошибку базы данных. Для нас это скорее всего означает что такого пользователя нет. Но для логики приложения полезней наш собственный тип UserNotFoundError с указанием логина проблемного пользователя, а не обезличенная ошибка БД — что мы и выбрасываем в обработчике исключения.

Проблема в том, что программисту часто хотелось бы знать, а почему это пользователь не найден. Например, чтобы сохранить в логах для дальнейшего разбирательства.

Для таких целей служит конструкция raise ... from ....

По PEP 3134 у объекта исключения имеется несколько обязательных атрибутов.

В первую очередь это __traceback__, содержащий кусочек стека от места возникновения исключения до места его обработки.

Затем — __context__. Если исключение было создано в ходе обработки другого исключения (выброшено из except блока) — __context__ будет содержать то самое породившее исключение. Которое, в свою очередь тоже может иметь установленный __context__. Этот атрибут равен None если наше исключение — самое первое и не имеет предшественников.

__context__ устанавливается автоматически.

В отличие от контекста __cause__ устанавливается только если исключение было выброшено конструкцией raise ... from ... и равно значению from.

Если исключение выбрасывалось простым raise ... то __cause__ будет равно None в то время как __context__ всегда будет содержать породившее исключение если оно существует.

Для вывода исключения со всей информацией служит набор функций из модуля traceback, например traceback.print_exc().

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

Иногда программисту может быть нужно отбросить породившие исключения как не имеющие смысла при выводе traceback. Для этого появилась форма записи

raise exc from None

PEP 409 и PEP 415 рассказывают как это работает:

У исключения всегда есть атрибут __supress_context__. По умолчанию он равен False.

Конструкция raise ... from ... записывает from в __cause__ и устанавливает __supress_context__ в True.

Тогда семейство функций traceback.print_exc() печатают цепочку если явно указан (не равен None) __cause__ или есть __context__ и при этом __supress_context__ равен False.

Изложение получилось несколько длинным, но сократить текст без потери смысла у меня не вышло.

Семейство OSError

Последняя проблема о которой хотелось бы рассказать — это типы исключений порожденные вызовами операционной системы.

До Python 3.3 существовало много разных типов таких исключений: os.error, socket.error, IOError, WindowsError, select.error и т.д.

Это приводило к тому, что приходилось указывать несколько типов обрабатываемых исключений одновременно:

try:
    do_something()
except (os.error, IOError) as ex:
    pass

Ситуация на самом деле была еще хуже: очень легко забыть указать еще одно нужное исключение, которое может внезапно прилететь. Дело в том что исключения операционной системы часто никак не проявляют себя при разработке. На машине программиста всё работает отлично и он не подозревает о возможных проблемах. Как только программа выходит в production пользователь вдруг ловит что-то неожиданное и программа аварийно завершается. Все опечалены.

Проблема решена в PEP 3151: весь этот зоопарк теперь является псевдонимами для OSError. Т.е. пишите OSError и не ошибетесь (прочие имена оставлены для обратной совместимости и облегчения портирования кода на новую версию).

Давайте рассмотрим ещё один аспект исключений, порожденных операционной системой.

У OSError есть атрибут errno, который содержит код ошибки (список всех возможных символьных констант для ошибок можно посмотреть в модуле errno).

Открываем файл, получаем OSError в ответ. Раньше мы должны были анализировать ex.errno чтобы понять, отчего произошла ошибка: может файла нет на диске, а может нет прав на запись — это разные коды ошибок (ENOENT если файла нет и EACCES или EPERM если нет прав).

Приходилось строить конструкцию вроде следующей:

try:
    f = open(filename)
except OSError as ex:
    if ex.errno == errno.ENOENT:
       handle_file_not_found(filename)
    elif ex.errno in (errno.EACCES, errno.EPERM):
       handle_no_perm(filename)
    else:
       raise  # обязательно выбрасывать не обработанные коды ошибки

Теперь иерархия расширилась. Привожу полный список наследников OSError:

OSError
 +-- BlockingIOError
 +-- ChildProcessError
 +-- ConnectionError
 |    +-- BrokenPipeError
 |    +-- ConnectionAbortedError
 |    +-- ConnectionRefusedError
 |    +-- ConnectionResetError
 +-- FileExistsError
 +-- FileNotFoundError
 +-- InterruptedError
 +-- IsADirectoryError
 +-- NotADirectoryError
 +-- PermissionError
 +-- ProcessLookupError
 +-- TimeoutError

Наш пример можем переписать как:

try:
    f = open(filename)
except FileNotFound as ex:
    handle_file_not_found(filename)
except PermissionError as ex:
    handle_no_perm(filename)

Гораздо проще и понятней, правда? И меньше мест, где программист может ошибиться.

Заключение

Переходите на Python 3.3, если можете. Он хороший и облегчает жизнь.

Новые плюшки в вопросе, касающемся исключений, я показал.

Если использовать новый питон не позволяют обстоятельства — пишите на чём есть, но помните как правильно это делать.

3 комментария:

  1. > печатается либо явная цепочка есть установленный __cause__

    Скорее всего тут опечатка.

    ОтветитьУдалить
  2. Все понятно стало. Спасибо за статью

    ОтветитьУдалить