Почему мы отказались от выражения «assert» в Python

Привет! Меня зовут Дмитрий, я backend-разработчик. В текущем проекте на Python мы отказались от использования выражений с ключевым словом assert, и в этой статье я расскажу почему.

1dc29f4325ac8e944a65432f11df58b7.webp

Ключевое слово 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, вы снизите риск появления сложно отлавливаемых багов и повысите качество кода в целом. Удачи!

© Habrahabr.ru