Как Python исключения обрабатывает
Люди, которые изучали 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) команды байткода, спровоцировавшей исключение. Т.е. происходит поиск (насколько я понял, бинарный поиск) такой строки таблицы, что ([...)
— полуинтервал). Если поиск успешен, стек разгребается до достижения нужной глубины и управление передается обработчику исключения. Если подходящей строки нет, то программа падает. 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 и разработку.