Тревога по поводу assertions

Большинство production codebase делятся на два лагеря. Лагерь А относится к assert как к декоративной приправе, посыпая им каждую вторую строку, пока функция не начинает читаться как юридический contract, написанный параноидальным юристом. Лагерь Б рассматривает assertions как подпорки только для development, вырезая их все на этапе сборки и надеясь, что код работает в production, потому что тесты прошли один раз.

Оба лагеря неправы. Вопрос не в том, использовать ли assert. Вопрос в том, что assertion на самом деле означает.

Assertion — это не обработка ошибок. Это не валидация входных данных. Это не вежливое предложение. Assertion — это утверждение, что что-то невозможно. Если assertion срабатывает, ваша ментальная модель программы сломана. Это различие определяет всё о том, где assertions должны находиться и сколько их нужно писать.

Assertions нужны для invariants, а не для ошибок

Когда пользователь передаёт отрицательный возраст в ваш API, это ошибка. Ошибки ожидаемы. Ошибки заслуживают реальной обработки, логирования и сообщений для пользователя. Когда ваше внутреннее вычисление даёт отрицательное количество строк базы данных после якобы успешного запроса, это нарушение invariant. Этого никогда не должно происходить. Для этого и существуют assertions.

Это звучит очевидно, пока вы не начнёте читать production code. Я видел функции, которые assert, что строка непустая, а затем через три строки проверяют if (!str) и выбрасывают отформатированное исключение. Разработчик использовал оба инструмента для одного и того же условия, потому что так и не решил, какой из них является настоящим contract.

Вот правило. Если условие может быть вызвано внешним вводом, это не assertion. Если оно может быть вызвано только багом в вашем собственном коде, то это assertion.

def process_payment(user_id: str, amount_cents: int) -> Receipt:
    # NOT an assertion. Users or upstream services can send bad data.
    if amount_cents <= 0:
        raise ValueError("amount_cents must be positive")

    # NOT an assertion. The user_id comes from the outside world.
    if not user_id:
        raise ValueError("user_id is required")

    receipt = _charge_card(user_id, amount_cents)

    # THIS is an assertion. If charge_card returned None after
    # succeeding, our understanding of the universe is wrong.
    assert receipt is not None, "charge_card succeeded but returned None"

    # THIS is an assertion. A receipt with zero items after a
    # successful charge means our internal logic is broken.
    assert len(receipt.items) > 0, "receipt has no items after successful charge"

    return receipt

Первые две проверки охраняют boundary. Последние две охраняют внутреннюю согласованность системы. Их смешение создаёт путаницу о том, кто за что отвечает.

Потолок в три assertion

Если вы обнаруживаете, что пишете больше трёх assertions в одной функции, у вас одна из двух проблем. Либо ваша функция делает слишком много, либо ваши invariants слишком размыты, чтобы их можно было обеспечить.

Функция с двенадцатью assertions не является defensive. Она неуверенна. Автор не доверяет коду, который её вызывает, коду, который она вызывает, или данным, текущим между ними. Эта неуверенность должна быть разрешена через refactoring, а не через добавление новых assert.

Практический предел определяется тем, что разработчик может удержать в голове. Функция должна иметь один чёткий contract. Этот contract подразумевает небольшое число invariants. Если вам нужна дюжина assertions, чтобы чувствовать себя в безопасности, ваша функция, вероятно, поглотила ответственности, которые принадлежат другим местам.

Разделите функцию. Выделите часть, которая трансформирует данные. Выделите часть, которая вызывает внешние сервисы. Дайте каждой выделенной функции её собственный небольшой набор invariants. Три assertions на функцию — это предупреждающий свет. Пять — это спущенное колесо.

Assertions в production: включить или выключить?

Разные языки делают разный выбор. Python вырезает assert при запуске с флагом -O. Компиляторы C и C++ рутинно удаляют assertions в release builds. В JavaScript вообще нет встроенного assert. Вы либо используете polyfill, либо библиотеку, которая остаётся активной в production.

Это создаёт настоящую дилемму. Если вы вырезаете assertions, вы теряете safety net именно тогда, когда он нужен больше всего. Баги, которые проявляются только в production, будут беззвучно портить данные вместо того, чтобы fail fast. Если вы оставляете их, вы рискуете крашить production-процесс из-за условия, которое, хотя теоретически невозможно, на самом деле не является фатальным.

Ответ зависит от стоимости продолжения. Если нарушение invariant означает, что следующая операция повредит базу данных или утечёт sensitive data, assertion должна крашить процесс. Hard stop лучше, чем silent breach. Если нарушение invariant означает слегка неправильную запись в логе или небольшой UI glitch, залогируйте это и продолжайте.

// This should probably crash. Continuing with a null user
// after auth succeeded is a security hole waiting to happen.
assert(user !== null, "auth middleware returned null user after success");

// This should probably not crash. A stale cache timestamp
// is annoying but not dangerous.
if (cache.timestamp > Date.now()) {
  logger.warn("cache timestamp is in the future, ignoring");
}

Не каждый invariant заслуживает одинаковой позиции. Научитесь отличать «это должно остановиться» от «это странно, но переживаемо».

Что мы пробовали, и это не сработало

На раннем этапе одного проекта мы попробовали assert каждое предусловие функции. Каждый аргумент проверялся на null, type, range и format. Результат был предсказуем. Тесты проходили прекрасно. Production крашился в первый раз, когда стороннее API вернуло поле в виде строки вместо числа.

Проблема была не в assertion. Проблема была в том, что мы делали assert на данных, находящихся вне нашего контроля, а затем компилировали с включёнными assertions в production. Некорректный внешний ответ убивал наш процесс вместо того, чтобы быть санирован и обработан. Мы построили систему, которая была внутренне согласованной и внешне хрупкой.

Мы научились отделять boundary от interior. На boundary парсите и валидируйте агрессивно. Превращайте внешний хаос во внутреннюю уверенность. Внутри boundary assert те invariants, которые определяют эту уверенность. Assertions остались. Валидация входных данных переехала в явные parsing functions, которые возвращали Result types вместо выброса исключений.

Практический checklist

Прежде чем добавить assertion, пройдите по этому списку:

  1. Может ли внешний ввод вызвать это? Если да, используйте validation, а не assertion.
  2. Если это сработает в production, должен ли процесс остановиться? Если нет, залогируйте warning вместо этого.
  3. Есть ли в этой функции уже три или более assertions? Если да, подумайте о refactoring, прежде чем добавлять ещё один.
  4. Будет ли этот assertion всё ещё понятен тому, кто читает код через полгода? Obscure assertions удаляются при refactoring. Чёткие выживают.

Assertions — это инструмент коммуникации не меньше, чем инструмент безопасности. Они говорят следующему разработчику: «это условие невозможно по дизайну». Если условие на самом деле невозможно не по дизайну, assertion лжёт. А ложь в production code дорого обходится.

FAQ

Стоит ли делать assert на аргументах функции?

Только если caller — тоже ваш код, и аргумент является результатом внутренней логики, а не внешнего ввода. Публичные API-функции должны валидировать. Приватные helper-функции могут assert invariants о значениях, которые они получают.

А как насчёт TypeScript? Он уже ловит null на этапе компиляции.

Type system TypeScript — это мощный assertion-слой, но он исчезает на runtime. Используйте его для всего, что компилятор может доказать. Добавляйте runtime assertions для пробелов: API-ответы, deserialized data и любой as cast, который обходит type checker.

Вредят ли assertions производительности?

В большинстве языков хорошо размещённая assertion стоит микросекунд. Если вы делаете assert внутри tight loop, обрабатывающего миллионы элементов, вынесите assertion за пределы цикла. Проверяйте invariant на batch, а не на каждом элементе.

Стоит ли писать custom assert-функции?

Только когда встроенное сообщение assertion было бы бесполезным. Custom assertNonEmpty, которая выводит реальную длину массива, полезнее, чем generic assert len(items) > 0, которая крашится без контекста. Держите их маленькими. Не стройте assertion framework.