Привет! Меня зовут Дмитрий, я backend-разработчик. В текущем проекте на Python мы отказались от использования выражений с ключевым словом assert
, и в этой статье я расскажу почему.
Ключевое слово assert
было впервые добавлено в язык ещё в версии 1.5, с тех пор оно широко используется несмотря на недостатки, которые мы рассмотрим ниже.
Синтаксис выражения не претерпел изменений с момента введения и имеет следующий вид: assert <condition>
или assert <condition>, <errormessage>
. Выражение assert
проверяет условие, и в случае его невыполнения вызывает AssertionError
. Это эквивалентно следующему коду:
if not expression:
raise AssertionError
AssertionError это один из многочисленных built-in типов ошибок в Python. Главное отличие AssertionError от других встроенных ошибок Python - отсутствие семантического смысла. Исходя из типа ошибки AssertionError невозможно понять, что именно стало причиной вызова исключения, поэтому такие ошибки обычно не отлавливаются.
Примеры использования
Обычно выражение assert используется как короткий и лаконичный способ бросить исключение в следующих сценариях:
В процессе отладки программы
Работая над кодом, приходится делать предположения о состоянии программы в каждый момент времени. Чтобы зафиксировать такие предположения, можно использовать выраженияassert
. Например, выражениеassert len(data) > 0
фиксирует предположение о том, что объектdata
не пустой. Это помогает сразу обнаружить, где и почему программа работает не так, как ожидалось. Такой подход особенно полезен на этапах разработки и тестирования, так как позволяет сразу зафиксировать некорректное состояние программы, вместо поиска причин неожиданного результата.-
Для утверждения типов
Мы используем mypy для проверки типов в нашем проекте. В большинстве случаев он корректно справляется с задачей вывода типов, но не всегда. Чтобы уточнить тип значения, которое вернёт функция, можно использовать краткое и прямолинейное выражениеassert
, например:async def create_vehicle_type(self, vehicle_type_create: VehicleTypeCreate) -> int: query = SQL( """ INSERT INTO public.vehicle_type (vehicle_class_id, name, code) VALUES (%(vehicle_class_id)s, %(name)s, %(code)s) RETURNING id """ ) async with self._con.cursor(row_factory=scalar_row) as cur: res = await ( await cur.execute(query, vehicle_type_create.model_dump()) ).fetchone() assert res is not None return res
В данном случае результат выполнения запроса всегда возвращает id созданного объекта в БД, но статический анализатор типов догадаться об этом не может и будет выводить ошибку. Поэтому добавим соответствующее утверждение -
assert res is not None
. -
При работе с фреймворками и библиотеками
Тестовый фреймворк pytest является эталонным примером использования выраженияassert
в Python. Он позволяет использовать нативные питоновские конструкцииassert
, автоматически перехватывает их и предоставляет на их основе расширенные сообщения об ошибках. Например, если условиеassert a == b
не выполняется, pytest выводит подробное сообщение, показывающее значения переменныхa
иb
, а также их различия. Это делает тесты лаконичными и более читаемыми, в отличие от специальных конструкций вродеself.assertEqual
в unittest из стандартной библиотеки Python. Благодаря такой интеграции, можно сосредоточиться на написании логики тестов, не беспокоясь о дополнительных инструментах для отладки и анализа ошибок.Ещё один пример - библиотека для валидации и сериализации данных - Pydantic. В 2020 году авторы добавили возможность использования AssertionError в валидаторах, наряду с ValueError. Допускается также использование краткого выражения
assert
:def check_alphanumeric(cls, v: str, info: ValidationInfo) -> str: if isinstance(v, str): # info.field_name is the name of the field being validated is_alphanumeric = v.replace(' ', '').isalnum() assert is_alphanumeric, f'{info.field_name} must be alphanumeric' return v
В этом примере, в случае если входные данные не валидны, вызывается исключение
AssertionError
которое впоследствии обрабатывается Pydantic. Я считаю этот кейс неудачным, давайте разберёмся почему.
Подводные камни
Если обратиться к документации python, то мы найдём следующее определение для выражения assert <condition>, <error_message>
:
if __debug__:
if not expression: raise AssertionError
или
if __debug__:
if not expression1: raise AssertionError(expression2)
Обнаруживается, что проверка условия внутри assert
дополнительно обёрнута в условие if __debug__:
. Таким образом, выражение assert
выполняется только в случае, если условие if __debug__
истинно. Такая деталь часто не принимается во внимание, например в документации pydantic оба варианта написания валидаторов, с использованием выражения assert
или исключения ValueError
, упоминаются как идентичные, без специальных оговорок.
__debug__
- это build-in переменная python, которая по умолчанию принимает значение True
. Да, запуская любой python код с помощью команды python main.py
в командной строке, фактически вы запускаете python-код в режиме дебага! Изменение значения переменной __debug__
в рантайме python запрещено, а единственный легальный способ присвоить этой переменной значение False
- запускать интерпретатор CPython с флагом -O
или -OO
.
Флаг -O
(Optimize) интерпретатора CPython используется для запуска Python-программ в оптимизированном режиме. Он может использоваться в сценариях, где производительность критична, а логика программы уже полностью протестирована, например, в продакшене или в вычислительных задачах с интенсивной нагрузкой. При использовании флага -OO
, при компиляции исходного кода в байт код, дополнительно игнорируются докстринги. Существует также альтернативный способ включения оптимизированного режима - с помощью переменной окружения PYTHONOPTIMIZE
.
На самом деле, с включенной опцией -O
интерпретатор изменяет значение переменной __debug__
на False
. Как следствие, все выражения assert
игнорируются в процессе компиляции байт-кода. Это позволяет немного снизить размер .pyo
файлов и не производить "лишних" вычислений.
Фактически запуск интерпретатора в optimized режиме изменяет поведение вашей программы. Например, приведённый выше валидатор pydantic просто перестаёт работать! Согласитесь, неочевидное поведение. Такой баг бывает сложно отловить, т.к. запуская тот же исходный код в другом окружении, вы получаете абсолютно корректную работу.
Кроме того, если вычисление выражения внутри конструкции assert
подразумевает выполнение функции с побочными эффектами (например запись в лог), то эти действия также не будут выполнены. Строка кода содержащая выражение assert
игнорируется.
Как быть
Таким образом, выражение assert
имеет неявное поведение, зависящее от окружения в котором выполняется код. Можно, учитывая всё описанное выше, контролировать использование assert
, отслеживая побочные эффекты, или условиться не использовать optimized режим интерпретатора. Но:
Если существует хоть малейшая вероятность негативного события, то на большой дистанции нельзя гарантировать, что оно не произойдёт.
Нельзя наверняка знать, в каком окружении будет запущен ваш код (использование флага -O
в продакшен окружении - широкая практика). Поэтому, надёжное решение - отказаться от использования assert
в вашем коде. Тем более что, сообщество python придерживается такого же мнения - в ruff уже есть соответствующее правило, которое изначально было добавлено в пакете flake8-bandit.
Заключение
В этой статье я сделал упор на особенности работы интерпретатора с включенном или отключенным флагом -O
. Хотя это не единственный недостаток бесконтрольного использования выражений assert
. Вместо этого, я предпочитаю использовать явные исключения такие как ValueError
, или кастомные типы исключений, которые принимают аргументы для генерации текстового описания ошибки (см. подробнее здесь).
В заключение хочу порекомендовать всегда использовать линтеры в ваших проектах на python. Линтеры помогут автоматически находить использование assert
и предотвращать появления множества других ошибок. На данный момент ruff содержит более 800 встроенных правил, которые основаны на обобщённом опыте сообщества python. Запуская линтеры в CI пайплайне или локально через pre-commit, вы снизите риск появления сложно отлавливаемых багов и повысите качество кода в целом. Удачи!
Комментарии (17)
zo0Mx
23.01.2025 20:37эмм... а зачем вы его использовали в продакшене, если в доке буквально написано: "assert предназначен для отладки"
https://docs.python.org/3/reference/simple_stmts.html#the-assert-statementdanilovmy
23.01.2025 20:37Это стоит рассказать ещё и разработчикам pytest, потому что debugging ≠ testing. Но они упорно используют assert в тестировании.
zo0Mx
23.01.2025 20:37не вижу противоречий в использовании assert при тестировании:
1. кому и для чего может понадобиться запускать тесты в отладочном режиме?
2. тестирование это тоже своего рода отладкаAndrey_Solomatin
23.01.2025 20:37кому и для чего может понадобиться запускать тесты в отладочном режиме?
В имели в виду с интерпретатором в режиме оптимизации?
zo0Mx
23.01.2025 20:37именно это я и имел в виду, абсолютно верно
funca
23.01.2025 20:37Какой смысл тестировать отладочную сборку, когда в прод будет исполняться совсем другой код?
Сам pytest, как ни странно, работает и с включённой оптимизацией в питоне. Но с ограничениями: assert поддерживается только в модулях с тестами и плагинах. Это реализовано через кастомный загрузчик тестов, который фактически переписывает код. В том числе он заменяет assert на raise AssertionError.
zo0Mx
23.01.2025 20:37Если вы один из тех редких индивидуумов, которые гоняют тесты в режиме оптимизации и вам не терпится об этом кому-то рассказать, то не пишите мне больше, это максимально не интересно.
funca
23.01.2025 20:37Не переживате так, берите пример со специалистов. На эту тему можно вполне конструктивно общаться https://discuss.python.org/t/does-anyone-use-o-or-oo-or-pythonoptimize-1-in-their-deployments/37671/8.
funca
23.01.2025 20:37Концептуально, ассерты это больше документация, нежели код.
Да, она выполняется в режиме отладки для удобства, но это вторично. Их отключают в сборках для продакшен, чтобы не снижать перфоманс.
Общее правило: assert не должны ни каким образом влиять на логику выполнения программы (как встроенная документация или логирование, например). Поведение программы с включенными ассертами должно быть точно таким же как и с выключенными.
Если нужна проверка, которая должна менять поток исполнения программы: валидация входных и выходных данных, проверка ошибок времени исполнения, и т.п - то нужно использовать другие выразительные средства языка (такие как if, raise, да хоть монады %). Но не assert.
В питоне подложили свинью: по умолчанию __debug__ включен и AssertionError наследуется от Exception. В результате они бросаются и перехватываются наряду с другими исключениями времени исполнения. Это дало соблазн горячим головам применять их в качестве API для управления логикой программы: в валидаторах, юнит тестах и т.п. Дичь, но от pytest уже ни куда не деться.
В общем, если использовать assert по делу, то ни чего плохого в них нет. Но в питоне, порой, здесь приходится бороться с когнитивным диссонансом.
NightShad0w
23.01.2025 20:37Собственно, все вопросы к авторам Pydantic. У них же должна быть веская причина игнорировать официальную документацию.
Все остальные указанные случаи - только указывают на смысл assert - отладка кода.
Аналогично в С, где assert натурально удаляется из кода при оптимизированной сборке. И все впитывают с самого начала знание не ожидать сайдэффектов при вызове ассерта. Ну потому зачем оставлять в коде проверки, которые не должны существовать, но были полезны при отладке.
А зачем кому-то запускать тесты с pytest с оптимизациями? Это же скорее всего юнит-тесты, упал-отладил. А для всего прочего со сложного логикой pytest.fail().
rexer
23.01.2025 20:37Суть assert, как уже подмечено выше, именно в проверке только внутренних инвариантов программы и тестах. И то, что в pydantic его затащили для проверок данных - это косяк проектирования и не верное использование инструмента.
Использование и применение очень похожи на то, как это реализовано в Java: использование-assert
KivApple
23.01.2025 20:37Это косяк авторов pydantic, а не дизайн. По идее им можно отправить баг репорт.
Ни в C/C++, ни в Python, ни в Java assert не предназначены для проверки чего-то в продакшене, это официально указано в документации и опция релизного режима их тихо и незаметно отключает.
funca
23.01.2025 20:37Есть дискуссия трехлетней давности https://github.com/pydantic/pydantic/discussions/4575. В v2 они сами не кидают AssertionError из валидаторов, но по-прежнему перехватывают, превращая в ошибки валидации.
Regis
Похоже вы так и не разобрались для чего нужен
assert
.Он нужен в первую очередь для "sanity checks": проверять то, что в продакшн системе валидировать не нужно (так как не имеет смысла или слишком дорого). Каждый упавший assert должен означать ошибку в вашем коде. Если вы допускаете, что в системе при каких-то обстоятельствах выражение в assert будет ложным, то в этом месте вам assert не подоходит - там должна быть полноценная валидация.
PS: В целом очень странно начинать статью с заведомо неправильного определения этого ключевого слова. 90% проблем и вопросов отпадает, если сразу написать правильное определение.
kosdmit Автор
С вашим пониманием назначения assert совершенно согласен. Но вот разработчики pydantic, например, так не считают. Там assert может использоваться для валидации входящих данных. Мне показалось это очень интересным, поэтому постарался обратить на это внимание в статье.
pesh1983
Это лишь значит, что разработчики pydantic неправильно понимают назначение assert. Иногда и в известных проектах такое бывает.