Привет! Меня зовут Дмитрий, я 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)


  1. Regis
    23.01.2025 20:37

    Похоже вы так и не разобрались для чего нужен assert.

    Он нужен в первую очередь для "sanity checks": проверять то, что в продакшн системе валидировать не нужно (так как не имеет смысла или слишком дорого). Каждый упавший assert должен означать ошибку в вашем коде. Если вы допускаете, что в системе при каких-то обстоятельствах выражение в assert будет ложным, то в этом месте вам assert не подоходит - там должна быть полноценная валидация.

    PS: В целом очень странно начинать статью с заведомо неправильного определения этого ключевого слова. 90% проблем и вопросов отпадает, если сразу написать правильное определение.


    1. kosdmit Автор
      23.01.2025 20:37

      С вашим пониманием назначения assert совершенно согласен. Но вот разработчики pydantic, например, так не считают. Там assert может использоваться для валидации входящих данных. Мне показалось это очень интересным, поэтому постарался обратить на это внимание в статье.


      1. pesh1983
        23.01.2025 20:37

        Это лишь значит, что разработчики pydantic неправильно понимают назначение assert. Иногда и в известных проектах такое бывает.


  1. zo0Mx
    23.01.2025 20:37

    эмм... а зачем вы его использовали в продакшене, если в доке буквально написано: "assert предназначен для отладки"
    https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement


    1. danilovmy
      23.01.2025 20:37

      Это стоит рассказать ещё и разработчикам pytest, потому что debugging ≠ testing. Но они упорно используют assert в тестировании.


      1. zo0Mx
        23.01.2025 20:37

        не вижу противоречий в использовании assert при тестировании:
        1. кому и для чего может понадобиться запускать тесты в отладочном режиме?
        2. тестирование это тоже своего рода отладка


        1. Andrey_Solomatin
          23.01.2025 20:37

          кому и для чего может понадобиться запускать тесты в отладочном режиме?

          В имели в виду с интерпретатором в режиме оптимизации?


          1. zo0Mx
            23.01.2025 20:37

            именно это я и имел в виду, абсолютно верно


            1. funca
              23.01.2025 20:37

              Какой смысл тестировать отладочную сборку, когда в прод будет исполняться совсем другой код?

              Сам pytest, как ни странно, работает и с включённой оптимизацией в питоне. Но с ограничениями: assert поддерживается только в модулях с тестами и плагинах. Это реализовано через кастомный загрузчик тестов, который фактически переписывает код. В том числе он заменяет assert на raise AssertionError.


              1. zo0Mx
                23.01.2025 20:37

                Если вы один из тех редких индивидуумов, которые гоняют тесты в режиме оптимизации и вам не терпится об этом кому-то рассказать, то не пишите мне больше, это максимально не интересно.


                1. 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.


  1. funca
    23.01.2025 20:37

    Концептуально, ассерты это больше документация, нежели код.

    Да, она выполняется в режиме отладки для удобства, но это вторично. Их отключают в сборках для продакшен, чтобы не снижать перфоманс.

    Общее правило: assert не должны ни каким образом влиять на логику выполнения программы (как встроенная документация или логирование, например). Поведение программы с включенными ассертами должно быть точно таким же как и с выключенными.

    Если нужна проверка, которая должна менять поток исполнения программы: валидация входных и выходных данных, проверка ошибок времени исполнения, и т.п - то нужно использовать другие выразительные средства языка (такие как if, raise, да хоть монады %). Но не assert.

    В питоне подложили свинью: по умолчанию __debug__ включен и AssertionError наследуется от Exception. В результате они бросаются и перехватываются наряду с другими исключениями времени исполнения. Это дало соблазн горячим головам применять их в качестве API для управления логикой программы: в валидаторах, юнит тестах и т.п. Дичь, но от pytest уже ни куда не деться.

    В общем, если использовать assert по делу, то ни чего плохого в них нет. Но в питоне, порой, здесь приходится бороться с когнитивным диссонансом.


  1. NightShad0w
    23.01.2025 20:37

    Собственно, все вопросы к авторам Pydantic. У них же должна быть веская причина игнорировать официальную документацию.

    Все остальные указанные случаи - только указывают на смысл assert - отладка кода.

    Аналогично в С, где assert натурально удаляется из кода при оптимизированной сборке. И все впитывают с самого начала знание не ожидать сайдэффектов при вызове ассерта. Ну потому зачем оставлять в коде проверки, которые не должны существовать, но были полезны при отладке.

    А зачем кому-то запускать тесты с pytest с оптимизациями? Это же скорее всего юнит-тесты, упал-отладил. А для всего прочего со сложного логикой pytest.fail().


  1. rexer
    23.01.2025 20:37

    Суть assert, как уже подмечено выше, именно в проверке только внутренних инвариантов программы и тестах. И то, что в pydantic его затащили для проверок данных - это косяк проектирования и не верное использование инструмента.

    Использование и применение очень похожи на то, как это реализовано в Java: использование-assert


  1. tabfor
    23.01.2025 20:37

    Я бы вообще от Python отказался.


  1. KivApple
    23.01.2025 20:37

    Это косяк авторов pydantic, а не дизайн. По идее им можно отправить баг репорт.

    Ни в C/C++, ни в Python, ни в Java assert не предназначены для проверки чего-то в продакшене, это официально указано в документации и опция релизного режима их тихо и незаметно отключает.


    1. funca
      23.01.2025 20:37

      Есть дискуссия трехлетней давности https://github.com/pydantic/pydantic/discussions/4575. В v2 они сами не кидают AssertionError из валидаторов, но по-прежнему перехватывают, превращая в ошибки валидации.