Люди, которые изучали Python в качестве своего первого языка программирования, наверняка знакомы с идиомой EAFP (Easy to Ask Forgiveness than Permission - проще просить прощения, чем разрешения). Суть идиомы можно свести к следующему: если вам нужно выполнить некоторую последовательность действий, которая может завершиться возникновением исключения, то легче просто обработать это исключение, чем пытаться предусмотреть все условия, при которых исключения не будет. В Python-коде это будет выглядеть, как использование try-except блока. С точки зрения разработки такой подход позволяет писать более чистый код, т.к. вы сосредотачиваетесь на реальной логике программы, а не на логике обработки исключений. Смотрите сами, в каком случае легче понять, что делает код, и что там вообще происходит?

Проверка через if:

def divide(divisible: float, divider: float) -> float | None:
    if divider == 0:
        print("It is forbidden to divide by 0!")
        return
    
    return divisible / divider

EAFP:

def divide(divisible: float, divider: float) -> float | None:
    try:
        return divisible / divider
    
    except ZeroDivisionError:
        print("It is forbidden to divide by 0!")

В первом случае первое, что вас встречает - логика предотвращения возникновения исключительных ситуаций. А сама бизнес-логика кода находится в самом конце тела функции. Во втором же случае, мы сразу видим логику, видим, что она может привести к исключительной ситуации, а также видим, к какой именно - ZeroDivisionError. Второй вариант выглядит гораздо понятнее. Поэтому часто (но далеко не всегда) идиома EAFP предпочтительнее прочих подходов, во всяком случае в Python. Кстати, этот пример я подглядел на RealPython. У них есть очень неплохая статья про сравнения различных подходов к обработке исключений в Python. Там подробно написано что, когда и почему стоит использовать.

Но поговорить я хотел не об этом, а вот о чем. Значительная часть моих знакомых и друзей занимаются профессиональной разработкой на C++. При знакомстве с кодом некоторых Python-программ у них возникают вопросы типа: "Почему в Python так часто используется try-except блок? Неужели это не создает дополнительных расходов для интерпретатора?" Обычно на этот вопрос я отвечал, что try-except - это более питонично, и приводил в качестве аргументов все то, что я написал выше. Т.е., да, фактически, на вопрос я не отвечал ничего дельного, потому что и сам не знал, а как это технически работает. Этим текстом закрываю пробелы в своих знаниях, да и вам, надеюсь, это будет интересно.

Итак, начиная с версии 3.11 обработка исключений выглядит следующим образом. В момент компиляции вашего Python-кода (я напоминаю, что перед исполнением python-код компилируется в python-байткод, который исполняется интерпретатором) интерпретатор строит таблицу исключений (exception table). Таблица исключений строится на основе try-except блоков, которые встречаются в коде вашей программы. Таблица выглядит примерно следующим образом (это примерное и упрощенное, а не истинное представление):

|start-offset| end-offset |   target   |stack-depth| push-lasti |
|------------|------------|------------|-----------|------------|
|    int     |    int     |    int     |    int    |    bool    |
|начало диапа|конец диапа |оффсет обра |глубина    |логический  |
|зона, для ко|зона, для ко|ботчика     |стека      |флаг для воз|
|торого дейст|торого дейст|            |           |вращения в  |
|вует данный |вует данный |            |           |стек оффсета|
|обработчик  |обработчки  |            |           |исключения  |
|____________|____________|____________|___________|____________|

В таблицу вносятся только инструкции, так или иначе покрытые try-except блоками. Таблица исключений используется только в том случае, если исключение было фактически возбуждено. Если исключения нет, то try-except блок не создает почти никаких расходов на выполнение кода. Данный подход называется zero-cost обработка исключений. Поэтому в тех случаях, когда исключения действительно исключительны, т.е. возникают нечасто, предпочтительнее использовать try-except блок, а не if-блок. Проверка if выполняется всегда и стоит дороже, в то время, как try-except в случае, если исключения нет, почти ничего не стоит (тут можно посмотреть на сравнение производительности).

Если же исключение есть, интерпретатор проверяет по таблице, какому диапазону принадлежит отступ (offset) команды байткода, спровоцировавшей исключение. Т.е. происходит поиск (насколько я понял, бинарный поиск) такой строки таблицы, что offset_{comand} \in [offset_{start}; offset_{end}) ([...) - полуинтервал). Если поиск успешен, стек разгребается до достижения нужной глубины и управление передается обработчику исключения. Если подходящей строки нет, то программа падает. push-lasti нужен для перевозбуждения исключения после его обработки. Данная логика используется, например, в блоке finally.

Чтобы лучше понять, как все это работает, рассмотрим простой пример. Напишем простой try-except блок и дизассемблируем его с помощью модуля dis. Оговорюсь, что результат работы модуля dis зависит от версии интерпретатора. В данном примере я использовал версию 3.11.1.

Пример кода:

import dis

try_except = """\
try:
    1 / 0
except:
    pass
"""

dis.dis(try_except)

Вывод:

  0           0 RESUME                   0

  1           2 NOP

  2           4 LOAD_CONST               0 (1)
              6 LOAD_CONST               1 (0)
              8 BINARY_OP               11 (/)
             12 POP_TOP
             14 LOAD_CONST               2 (None)
             16 RETURN_VALUE
        >>   18 PUSH_EXC_INFO

  3          20 POP_TOP

  4          22 POP_EXCEPT
             24 LOAD_CONST               2 (None)
             26 RETURN_VALUE
        >>   28 COPY                     3
             30 POP_EXCEPT
             32 RERAISE                  1
ExceptionTable:
  4 to 12 -> 18 [0]
  18 to 20 -> 28 [1] lasti

В данном примере нам интересен второй столбец с числами. В этом столбце находятся смещения команд байткода, которые и вносятся в таблицу исключений. Также нам интересна сама таблица, представление которой выведено в конце листинга байткода. По самой таблице видно, какие команды находятся в теле try-except блока. Это команды с отступами 4-12, причем 12 в диапазон не включается. В случае возникновения исключения в момент выполнения команд с 4 по 8, программа продолжит свое выполнения с команды с отступом 18, а команды 12, 14 и 16 выполнены не будут. Причем, если исключения не будет, выполнение команды завершится на инструкции со смещением 16, и ни до какого обработчика исключений мы не дойдем. Собственно, поэтому никаких дополнительных расходов на использование try-except блока в случае отсутствия исключения и не будет. В случае же, если исключение произойдет, расходы будут. Нам придется найти нужную строку в таблице исключений, выкинуть из стека все лишнее и перейти к нужной инструкции. Поэтому в случае, если исключения потенциально способны возникать чаще обычного, стоит воспользоваться if-блоками. Тут опять сошлюсь на сравнения производительности от RealPython.

В завершении хочу лишний раз отметить, что в программировании нет серебряных пуль, и бездумное использование идиомы EAFP, потому что это более питонично, или LBYL (те самые проверки if), потому что вы так привыкли - это не лучший путь. Да, у try-except блока есть свои преимущества. Но также есть и ряд ограничений, которые будут оказывать влияние на производительность ваших программ.

Также оставлю ссылку на внутреннюю документацию CPython, в которой подробнее расписано о таблице исключений со ссылками на Си-код.

P.S. Если вам понравился текст, приглашаю вас в свой канал, где я пишу небольшие заметки про Python и разработку.

Комментарии (25)


  1. IGR2014
    27.12.2024 15:11

    Значительная часть моих знакомых и друзей занимаются профессиональной разработкой на C++. При знакомстве с кодом некоторых Python-программ у них возникают вопросы типа: "Почему в Python так часто используется try-except блок? Неужели это не создает дополнительных расходов для интерпретатора?"

    Видимо, они не в курсе что в том-же C++ механизм исключений настолько хорошо отлажен и оптимизирован что практически не создаёт дополнительных расходов...


    1. bolk
      27.12.2024 15:11

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


    1. santjagocorkez
      27.12.2024 15:11

      Нет, они просто не в курсе, что любой вызов, проходящий через Python C-API, автоматически создает тонну контекстного всякого, в том числе Frame Objects, даже тогда, когда вызов не включен в контекст try-блока. С этой точки зрения исключения в Python и правда бесплатные.


  1. MountainGoat
    27.12.2024 15:11

    Исключения гадость.

    Я немножко покодил на Расте и теперь когда вижу что в Питоне бросают исключения, я бросаюсь матом. Проблема чисто практическая: вот смотрю я в IDE на throw, и как мне увидеть catch который его поймает? Как, Карл? При использовании кодов ошибок типа Result <T> обработку ошибок всегда можно отследить так же, как и путь правильного результата. А отлов исключения может быть где угодно по коду дальше.

    А в Питоне ещё и раньше.

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


    1. evgenyk
      27.12.2024 15:11

      Странно. Как бы там, где есть catch выше по дереву вызовов. В чем проблема?


      1. MountainGoat
        27.12.2024 15:11

        Проблема в том, где это "выше по дереву вызовов" в асинхронном приложении, в гуёвне с event loop-ом и прочем модерне. Там зачастую фиг поймёшь, через какие места проходит последовательность, которая будет разворачиваться назад если случится исключение. Место, которое обрабатывает исключение, может даже не иметь исходный вызов в области видимости.


        1. evgenyk
          27.12.2024 15:11

          Асинхронщина везде, это оверинженеринг и болезнь!


    1. zuko3d
      27.12.2024 15:11

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


      1. MountainGoat
        27.12.2024 15:11

        Существует код, где мы экономим вручную каждый байт памяти. Там нужно использовать исключения. И вряд ли там используют Питон. (Вот только там любят языки или их версии без исключений)

        В остальных случаях исключения не нужно использовать никогда, так как есть альтернативы более надёжные в плане последующего рефакторинга и сопровождения.


        1. evgenyk
          27.12.2024 15:11

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


    1. rnv812
      27.12.2024 15:11

      А вы код проекта обычно с самого низшего уровня начинаете читать?) Исключения позволяют делегировать обработку нештатной ситуации управляющему слою, что делает код более гибким и избавляет вас от необходимости удерживать больше деталей во внимании, что в свою очередь позволяет помещать во внимание больше деталей реализации функции.


      1. MountainGoat
        27.12.2024 15:11

        А вы код проекта обычно с самого низшего уровня начинаете читать?

        Не обычно, но частенько. А как вы делаете, когда задача - поправить конкретный баг в коде, исходные авторы которого давно растворились во мраке гитхаба?

        Исключения позволяют делегировать обработку нештатной ситуации управляющему слою

        Только исключения?

        def read_user_from_db (db_connection, id) -> Result[User]:
           ret = Result(User)
           if not db_connection.is_valid():
              return ret.error(DBError("No DB connection"))
           user = db.connection.read(id)
           if user is None:
              return ret.error(LogicError("Requested ID does not exist)"))
           return ret.ok(user)
        
        
        ...
        user = read_user_from_db() # user: Result[User]
        if user.is_err():
           if user.err().type() == DBError:
              ...
        user = user.ok() # user: User

        Точно так же всё передаёт. Однако впоследствии можно 1) Посмотреть, есть ли ошибка, но не обрабатывать. Или обработать только часть вариантов (Исключения можно перевбросить, но это не одно и то же, если стоит глобальный обработчик исключений) 2) Глянув на кусок кода сразу понять, обрабатывали уже ошибку (там User) или нет (там (Result[])


    1. qss53770
      27.12.2024 15:11

      Или уметь читать, ведь в трейсе ошибки всегда чётко пишется что не так


  1. SergeiMinaev
    27.12.2024 15:11

    def divide(divisible: float, divider: float) -> float | None:
        if divider == 0:
            print("It is forbidden to divide by 0!")
            return
        
        return divisible / divider

    Имхо, если функция называется divide, то она должна divide и всё тут. И попадать в неё должны корректные данные. Мне нравится проверку делать до выполнения действия. Как-то так:

    if not allowed_to_divide(divisible, divider):
      other_action()
    else:
      result = divide(divisible, divider)

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


    1. MountainGoat
      27.12.2024 15:11

      Вот только когда ты в проекте не один, то где-то когда-то джун обязательно вызовет divide без проверки.

      Тогда уж надо городить такое:

      def prepare_division(divisible, divider) -> PreparedDivision | None:
         ...
      
      def run_division(PreparedDivision) -> float:
         ...


      1. SergeiMinaev
        27.12.2024 15:11

        Вот только когда ты в проекте не один, то где-то когда-то джун обязательно вызовет divide без проверки.

        Тогда уж надо городить такое:

        Это один из вариантов. От джунов, в любом случае, защититься можно только с помощью ревью. Вообще, типичный пример - валидация данных перед записью в базу - все же знают, что данные надо проверять.


      1. santjagocorkez
        27.12.2024 15:11

        Когда ты в проекте не один, на divide вешается декоратор, и хоть обвызывайся.


  1. vadimr
    27.12.2024 15:11

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

    Для питона, впрочем, это не очень важно, так как его код в любом случае сильно не оптимизируется при компиляции. Но для оптимизирующих компиляторов C++ это серьёзная проблема.


  1. aeder
    27.12.2024 15:11

    Концепция исключений, как таковых - она изначально (когда её придумывали, наверно, уже лет 30 назад) казалась удобной и разумной - но на практике оказалась неудобной и неразумной, не считая одного или двух случаев.

    Основная проблема с идеологией try/catch - она состоит в том, что к тому времени, как мы исключение обрабатываем - мы полностью потеряли контекст, в котором оно возникло.

    Примеры с одним-двумя выражениями в блоке try() - они на самом деле бессмысленные, так как технически ничем не лучше "давайте обработаем ошибку прямо тут".

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

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

    Для С++ это выглядит как:

    bool some_func(... , std::string & a_err);

    В других языках - можно возвращать пару (реальный результат и сообщение о ошибке).

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

    Код уровнем выше может дополнить сообщение о ошибке. Например, функция проверки контрольной суммы файла может вернуть "файла нет/нет записи контрольной суммы/сумма неверна" - а уровнем выше, добавить - почему именно в этом файле должна быть контрольная сумма.

    Чтобы пользователь не задавал мне идиотских вопросов "почему твоя программа валится с исключением, которое мне ничего не говорит?"

    Да, можно кидать исключение со сформированным сообщением о ошибке - но это:

    а) ничем не проще, чем сделать return false

    b) если в коде несколько вызовов, чтобы добавить к ним контекст - придётся каждый окружать блоком try/catch. Это чисто физуально будет больше кода, чем просто:

    if (! some_func(...,a_err))

    {

    a_error = "Additional context, failed: " + a_err;

    return false;

    }

    Аж на две скобки меньше

    ======================

    А когда использование исключений реально полезно? А в тех же (редких) случаях, когда раньше в языке С использовали longjump.

    Лично я вижу только два применения

    а) легитимно, в модульных тестах - вместо фатального завершения при срабатывании halt() или verify() (макросы, активно используемые в защитном программировании, штатно выдающие сообщение на консоль и завершающие с помощью abort()) - кинуть исключение и перехватить его в тесте. Удобно тестировать наличие/срабатывание проверок защитного программирования.

    б) хак. Современные линуксы позволяют выполнять throw из сингальных хандлеров (хотя и не гарантируют). Можно выйти из зациклившегося алгоритма по SIGALARM.


    1. evgenyk
      27.12.2024 15:11

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

      В питоне эта проблема решается элементарно.

      raise RuntimeError(f"Can not open file. Filename: {filename}")


  1. evgenyk
    27.12.2024 15:11

    А когда использование исключений реально полезно?

    Ну вот например в Fast API. Пишете большой API. Добавляете свои обработчики своих исключений, которые делают то что Вам нужно. Например логгируют и возвращают нужный респонс. В эндпойнтах же, когда что-то не так просто бросаете свои исключения. И четко знаете куда что пойдет и когда.

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


  1. tenzink
    27.12.2024 15:11

    Статья интересная, но пример с функцией divide просто ужасен. Исключения дёшевы, когда они возникают в исключительных (то есть редких) ситуациях. А представьте, что вы пишете вычислительный алгоритм, и равный нулю делитель встречается часто


    1. evgenyk
      27.12.2024 15:11

      В питоне обаботка исключений законная и вполне равноправная операция для управления ходом выполнения программы. Причем она требует (КМК) даже меньше ресурсов, чем if. Так что ничего страшного не случиться.

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


      1. tenzink
        27.12.2024 15:11

        Здесь нет управления циклом (если речь про StopIteration). Пример с divide ужасен по другой причине. Например, он явно противоречит идиоме EAFP. Посмотрите на код

        1 + divide(2, 0)

        Если в функции divide ничего не ловить, то пользователь дальше получить вполне понятный ZeroDivisionError и спокойно его обработает.

        В примере в статье будет менее ясныйTypeError , замусоренный stdout, просадка производительности и всё так же ловля с обработкой исключения. Спрашивается - во имя чего?


  1. RranAmaru
    27.12.2024 15:11

    Я питон не очень знаю, и что-то не пойму, что вернет ф-я divide из статьи? Исключение обработано внутри, значит наружу должна вернуть число, но кажется вернет None, что, думаю, вызовет еще одно исключение где-то ниже по коду.

    (Понятно, что пример иллюстративный, но хоть как-то работать он, считаю, все же обязан.)