Почему мы отказались от выражения «assert» в Python
Привет! Меня зовут Дмитрий, я backend-разработчик. В текущем проекте на Python мы отказались от использования выражений с ключевым словом assert
, и в этой статье я расскажу почему.
Ключевое слово assert
было впервые добавлено в язык ещё в версии 1.5, с тех пор оно широко используется несмотря на недостатки, которые мы рассмотрим ниже.
Синтаксис выражения не претерпел изменений с момента введения и имеет следующий вид: assert
или assert
. Выражение 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
:
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, вы снизите риск появления сложно отлавливаемых багов и повысите качество кода в целом. Удачи!