Comments 48
Значительная часть моих знакомых и друзей занимаются профессиональной разработкой на C++. При знакомстве с кодом некоторых Python-программ у них возникают вопросы типа: "Почему в Python так часто используется
try-except
блок? Неужели это не создает дополнительных расходов для интерпретатора?"
Видимо, они не в курсе что в том-же C++ механизм исключений настолько хорошо отлажен и оптимизирован что практически не создаёт дополнительных расходов...
Из вопроса как будто следует, что они спрашивают именно про интерпретатор.
Нет, они просто не в курсе, что любой вызов, проходящий через Python C-API, автоматически создает тонну контекстного всякого, в том числе Frame Objects, даже тогда, когда вызов не включен в контекст try-блока. С этой точки зрения исключения в Python и правда бесплатные.
Исключения гадость.
Я немножко покодил на Расте и теперь когда вижу что в Питоне бросают исключения, я бросаюсь матом. Проблема чисто практическая: вот смотрю я в IDE на throw, и как мне увидеть catch который его поймает? Как, Карл? При использовании кодов ошибок типа Result <T> обработку ошибок всегда можно отследить так же, как и путь правильного результата. А отлов исключения может быть где угодно по коду дальше.
А в Питоне ещё и раньше.
Чтобы в Питоне чётко знать, что случится если, скажем, файл не найден - нужно всего лишь внимательно изучить весь код проекта.
Странно. Как бы там, где есть catch выше по дереву вызовов. В чем проблема?
Проблема в том, где это "выше по дереву вызовов" в асинхронном приложении, в гуёвне с event loop-ом и прочем модерне. Там зачастую фиг поймёшь, через какие места проходит последовательность, которая будет разворачиваться назад если случится исключение. Место, которое обрабатывает исключение, может даже не иметь исходный вызов в области видимости.
Проблема в том, что это не дерево. Более того, с учётом нулевых возможностей для сокрытия определений в Python и его динамических возможностей новые связи в этот граф могут быть добавлены произвольным внешним кодом и даже в зависимости от рантайм-данных.
ИМХО, использование исключений должно быть предметом соглашений при написании софта. Впрочем, как и многое другое. Тогда они позволяют существенно упростить граф. Т.е. Желательно например ловить исключения в определенных, заранее оговоренных местах приложения. Тогда они будут резко упрощать граф, а не запутывать его.
Вот я приводил пример таких договоренностей в API написанной с использованием FastAPI.
Похожие договоренности можно использовать и в других областях.
Исключения в Python — и правда в этом аспекте являются проблемой. https://stackoverflow.com/questions/44282268/python-type-hinting-with-exceptions
То есть, например, в жабе выброс исключения (его тип) является частью сигнатуры метода, и компилятор строго следит за тем, чтобы все до единого исключения были обработаны на всём стеке исполнения (ЕМНИП, за исключением main). В Python же нельзя даже линтеру подсказку дать, нет такого синтаксиса, в итоге имеем какой-нибудь boto3, в котором кодерки оторвались по полной: у них исключение (его тип) само по себе объявляется динамически, поэтому даже нет типа, который можно в своем блоке except поставить, просто нечего импортировать на этапе запуска рантайма.
Т. е. существует код, в котором использование исключений вредит, поэтому их не нужно использовать никогда?
Существует код, где мы экономим вручную каждый байт памяти. Там нужно использовать исключения. И вряд ли там используют Питон. (Вот только там любят языки или их версии без исключений)
В остальных случаях исключения не нужно использовать никогда, так как есть альтернативы более надёжные в плане последующего рефакторинга и сопровождения.
В питоне исключения считаются вполне законным методом организации тецения кода. И часто позволяют упростить код. Но их испольование требует некоторй дисциплины и договоренностей.
Постоянная проверка результата вызова - проигрывает в производительности редким исключением
А вы код проекта обычно с самого низшего уровня начинаете читать?) Исключения позволяют делегировать обработку нештатной ситуации управляющему слою, что делает код более гибким и избавляет вас от необходимости удерживать больше деталей во внимании, что в свою очередь позволяет помещать во внимание больше деталей реализации функции.
А вы код проекта обычно с самого низшего уровня начинаете читать?
Не обычно, но частенько. А как вы делаете, когда задача - поправить конкретный баг в коде, исходные авторы которого давно растворились во мраке гитхаба?
Исключения позволяют делегировать обработку нештатной ситуации управляющему слою
Только исключения?
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[])
Или уметь читать, ведь в трейсе ошибки всегда чётко пишется что не так
Это же скриптовый язык. Тут исключения ловит пользователь, носом. :)
Язык родился в неудачное время: когда исключения уже вошли в моду, но ещё не было понятно, что это плохое решение для общего случая.
Не знаю как в Расте, но на Го мне этот подход не понравился.
В коде да легко отслеживается. Но то, что это всё нужно таскать за собой и регулярно проверять напрягает. В коде куча бойлерплейта.
У меня редко исключение ловится на следующим за ним уровне. Большинство из них уходит на самый верх.
И сообщения об ошиках просто божественны.
Error: failed to check due to failed to check due to storeId is required and must be specified
текст ошибки вы на любом языке можете сделать страшным, особенно используя вещи типа failed to и error.
Все уже сто лет как перешли на структурные логи где по уровню лога определяется статус лога и сообщение идет в формате «действие»: «ошибка»
“create user: already exists”
Все уже сто лет как перешли на структурные логи где по уровню лога определяется статус лога и сообщение идет в формате «действие»: «ошибка»
Видимо мне по жизни попадаются исключения.
С ошибками в чужом коде на го мне намного сложнее работать, чем с ошибками в Питоне и Java.
В моем примере это пользовательская ошибка. Продравщись через лишние слова я могу её решить. А вот когда в коде баг и текст ошибки может быть не правильным, то тут только по поиску по словам если это возможно.
но на Го мне этот подход не понравился.
В Go создатели решили отказаться от неудачных решений старых языков, но не потрудились придумать другие решения. Язык в целом получился странный, спасибо хоть дженерики всё-таки завезли. В Rust обработка ошибок без исключений сделана гораздо лучше.
https://earthly.dev/blog/golang-errors/
Типичный пример как объясняют красоту обработки ошибко на го.
Обработка разных типов ошибок. С полной потерей консеткста. Трейсбэк в комплект не входит. Намного хуже чем в Питоне из коробки. switch {
case errors.Is(err, ErrDivideByZero):
fmt.Println("divide by zero error")
default:
fmt.Printf("unexpected division error: %s\n", err)
}
пример который вы привели не используется в проде)
а свич кейс ничем в целом не отличается от ваших кетчей, вам точно так же надо проверять конкретное исключение
пример который вы привели не используется в проде)
Это пример из типичного тьюториала. К сожаленияю используется.
Расскажите какой подход работы с ошибками удобен. (я без подкола, хочу научиться, в го я не очень силён).
а свич кейс ничем в целом не отличается от ваших кетчей, вам точно так же надо проверять конкретное исключение
Это да.
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)
Пример с делением - примитивный и натянутый, но в сложных задачах это кажется удобным. А в случаях, когда нет резона делать полную валидацию, проще не париться и ловить исключения.
Вот только когда ты в проекте не один, то где-то когда-то джун обязательно вызовет divide без проверки.
Тогда уж надо городить такое:
def prepare_division(divisible, divider) -> PreparedDivision | None:
...
def run_division(PreparedDivision) -> float:
...
Вот только когда ты в проекте не один, то где-то когда-то джун обязательно вызовет divide без проверки.
Тогда уж надо городить такое:
Это один из вариантов. От джунов, в любом случае, защититься можно только с помощью ревью. Вообще, типичный пример - валидация данных перед записью в базу - все же знают, что данные надо проверять.
Когда ты в проекте не один, на divide вешается декоратор, и хоть обвызывайся.
Обработка невызываемых исключений всегда стоит возможности глубокой оптимизации кода в блоке try, так как в любом месте, где может произойти исключение, должен сохраняться контекст, созданный предыдущими операторами.
Для питона, впрочем, это не очень важно, так как его код в любом случае сильно не оптимизируется при компиляции. Но для оптимизирующих компиляторов C++ это серьёзная проблема.
Концепция исключений, как таковых - она изначально (когда её придумывали, наверно, уже лет 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.
А когда у вас в блоке кода 3-5 мест, где может быть выброшено исключение одного и того же типа (например, мы открываем на чтение два файла, и третий - на запись, и каждый из них может не существовать или быть недоступен) - нам надо или принудительно сохранять контекст, или мы этот контекст потеряем.
В питоне эта проблема решается элементарно.
raise RuntimeError(f"Can not open file. Filename: {filename}")
И тут возникает вопрос: если ловить это исключение на самом высоком уровне (или позволить программе грохнуться) - контекст, объясняющий ЗАЧЕМ этот файл пытались открыть - будет потерян.
Мы его хотели создать? Прочитать? Что это вообще за файл, почему - возможно, не надо этот файл создавать, чтобы программа работала нормально - возможно, его имя указано неверно в конфигурации.
А если ловить на уровень выше - то получается точно то же самое, как обрабатывать возвращаемое значение.
В общем и целом: идея исключений была: "пишем код, не заморачиваясь обработкой ошибок по месту - которая резко снижает читаемость/понимаемость кода - и обрабатываем все ошибки уже после алгоритма, в блоке catch" - а по факту получилось, что выигрыша (для читабельности/удобства) - нет, если мы хотим более-менее вменяемые сообщения о ошибках.
Это был пример. Поскольку вижу, что Вы не поняли, добавлю. В исключение вы можете в момент его вызова добавить любую информацию. Например номер строки программы. Или запись. Или логгировать.
Как я уже писал в примере про FastAPI. Вот возьмем пример про FastAPI. Вы получили какую-то ошибку, которая не дает выполнить запрос к Endpoint. Вам нужно залоггировать ошибку, вернуть ошибку в ответе на запрос и дать возможность расследовать Вашу ошибку. Мой путь:
Генерируем ID ошибки.
Пишем в лог: ID ошибки, номер строки кода.
Вызываем исключение, при этом добавляем в объект исключения ID ошибки, номер строки кода.
В обработчике исключения возвращаем ответ на запрос с кодом ошибки и ID ошибки.
Все. И вам не нужно возвращать информацию через все вызовы и ничего не потеряно.
А что вы под контекстом подразумеваете? Имя файла? Строку где произошла ошибка? Так это есть при открытии файла. При поимки исключения не обязательно его терять. Можно залогировать и кинуть дальше или сделать цепочку:
try:
open("not_exist")
except FileNotFoundError as e:
raise Exception("Some text") from e
"""
Traceback (most recent call last):
File "scratch.py", line 2, in <module>
open("not_exist")
FileNotFoundError: [Errno 2] No such file or directory: 'not_exist'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "scratch.py", line 4, in <module>
raise Exception("Some text") from e
Exception: Some text
"""
А когда использование исключений реально полезно?
Ну вот например в Fast API. Пишете большой API. Добавляете свои обработчики своих исключений, которые делают то что Вам нужно. Например логгируют и возвращают нужный респонс. В эндпойнтах же, когда что-то не так просто бросаете свои исключения. И четко знаете куда что пойдет и когда.
И это добавляет порядка. У Вас есть список исключений - он же для Вас список аварийных ситуаций. У Вас есть места, откуда Вы их бросаете. У Вас есть места, которые их обрабатывают. Все четко и понятно.
Рабочий подход. Веб фреймоворки его популяризируют. Но никто не мешает делать в том-же духе и остальные приложения.
При любом типе обработки ошибок нужно продумывать архитектуру. Какие ошибки выходят из модуля нуружу, какие нужно поймать внутри и конверировать во внешнии.
Конечно если кидать встроенные исключения то будет больно.
Статья интересная, но пример с функцией divide
просто ужасен. Исключения дёшевы, когда они возникают в исключительных (то есть редких) ситуациях. А представьте, что вы пишете вычислительный алгоритм, и равный нулю делитель встречается часто
В питоне обаботка исключений законная и вполне равноправная операция для управления ходом выполнения программы. Причем она требует (КМК) даже меньше ресурсов, чем if. Так что ничего страшного не случиться.
Скажем сделать цикл с выходами по исключениям вполне себе законное решение. Причем это иногда может упростить тело цикла.
Здесь нет управления циклом (если речь про StopIteration). Пример с divide ужасен по другой причине. Например, он явно противоречит идиоме EAFP. Посмотрите на код
1 + divide(2, 0)
Если в функции divide
ничего не ловить, то пользователь дальше получить вполне понятный ZeroDivisionError
и спокойно его обработает.
В примере в статье будет менее ясныйTypeError
, замусоренный stdout, просадка производительности и всё так же ловля с обработкой исключения. Спрашивается - во имя чего?
А представьте, что вы пишете вычислительный алгоритм
Если я делаю это на голом Python, то проблемы производительности при делении на ноль меня волнуют не слишком сильно.
Я питон не очень знаю, и что-то не пойму, что вернет ф-я divide из статьи? Исключение обработано внутри, значит наружу должна вернуть число, но кажется вернет None, что, думаю, вызовет еще одно исключение где-то ниже по коду.
(Понятно, что пример иллюстративный, но хоть как-то работать он, считаю, все же обязан.)
но кажется вернет None
Да, None - это ответ по-умолчанию. Не очень люблю, когда ответ не прописывают явно, но иногда так делают.
Да, в этом исполнении функция вернёт None. Можно, конечно, добавить в блок except логику и return, либо использовать блок else, который сработает если нет исключения и тп.
Но вот вопрос: проверять передаваемые данные или возвращаемые? У питонистов регулярно вижу такое:
from typing import Union
def boo():
try:
divide(10,2)
except ZeroDivisionError:
print('heh...')
def divide(a, b) -> Union[int, float]:
a / b
Хотя регулярно читаю мнения, что подобный подоход не PEP. Однако, если расходы на try/except низкие, то логика себя оправдывает и не требует тогда проверок на входе и выходе данных) Или нет?
Я сравниваю try/finally с with если у нас в enter есть ошибка то она передаётся в exit метод и там обрабатывается, если нету то сразу выходит из контекста. Кастомные исключения в самом объекте могут иметь фикс методы и логику.
Как Python исключения обрабатывает