
В коде часто встречаются проверки вида:
void process(Config* config) { if (config == nullptr) { // Но config создается в этом же модуле! log_error("Config is null"); return; } // ... }
хотя можно написать более явно и эффективно:
void process(Config* config) { assert(config != nullptr && "Config cannot be null"); // ... }
Или другой пример:
void processArray(int* array, size_t size) { if (array == nullptr && size > 0) { log_error("The pointer must not be nullptr if size > 0"); return; } // ... }
гораздо понятней будет:
void processArray(int* array, size_t size) { assert(array != nullptr || size == 0); // ... }
Такие if лишь загромождают код и создают иллюзию безопасности. Гораздо эффективнее использовать assert - он документирует ваши намерения, а его код не попадает в релизную сборку.
Плюсы такого решения:
Нет мусора в релизе. Каждый
if, проверяющий условия, которые не могут нарушиться в корректной программе - это dead code в релизе.Производительность. В "горячих" циклах, которые выполняются миллионы раз, замена
ifна assert уберет в релизе (NDEBUG) миллионы этих лишних проверок.Меньше размер исполняемого файла.
Код как документация.
assert(config != nullptr)чётко заявляет: "указатель не может быть нулевым!". Обычныйifв такой ситуации лишь намекает: "возможно, здесь нужно обработать ошибку".Лучше падение, чем тихая ошибка. Если ваше утверждение ложно - это баг. Лучше упасть сразу в Debug, чем в релизе тихо логировать ошибку и продолжать работу с некорректным состоянием.
Но есть и минусы:
Не использовать функции внутри assert. Код внутри assert должен быть идемпотентным (не иметь побочных эффектов). Например:
assert(initialize_connection() == SUCCESS);илиassert(my_vector.pop_back() == value);В релизной сборке эти функции не будут вызваны, что приведет к непредсказуемому поведению. Все проверки с побочными эффектами должны быть обычнымиif. Или вassertпередавать только результаты функций.Необходимость хорошего покрытия unit-тестами.
Тестировать в обеих конфигурациях (Debug и Release). В Debug вы проверяете, что
assertсрабатывают там, где нужно. В Release - что программа стабильно работает и без них.Небольшое отличие в поведении программы в Debug и Release.
Но нужно всегда помнить: assert НЕ для валидации пользовательского ввода! Его задача ловить ошибки программиста, а не ошибки пользователя или внешних систем. Если кратко: assert - для программиста, if - для пользователя.
Как работает магия с NDEBUG?
Секрет в стандартном макросе препроцессора, который так и называется - NDEBUG (сокращение от "No Debug"). Типичная реализация выглядит примерно так:
#ifdef NDEBUG // Если NDEBUG определен, assert превращается в "ничто" #define assert(condition) ((void)0) #else #define assert(condition) \ do { \ if (!(condition)) { \ std::abort(); \ } \ } while (0) #endif
В Release-сборке этот макрос обычно добавляется к флагам компиляции (например, -DNDEBUG в GCC/Clang/MSVC).
Проблема неиспользуемых переменных.
Если в assert передается переменная, которая больше нигде не используется, при компиляции с NDEBUG можно получить предупреждение:
warning: unused variable 'config' [-Wunused-variable]
Избавиться от предупреждений можно обернув assert в свой макрос, например:
#define ASSERT(x) \ do { \ bool cond = (x); \ (void)cond; \ assert(cond); \ } while (0)
Использование (void)cond подавит это предупреждение. Также поможет добавление атрибутов к таким переменным: __attribute((unused) или [[maybe_unused]].
Тестирование срабатывания assert.
Проверка того, что в определенных условиях действительно срабатывает assert, задача нетривиальная, но решаемая. Вот несколько практических подходов:
Использовать
GoogleTestиASSERT_DEATH/EXPECT_DEATHИспользование внешних стандартных библиотек (
-nostdlib). И затем mock-ировать функцию abort();Попытаться переопределить макрос assert, например:
#define assert(condition) \ do { \ if (!(condition)) { \ mock_abort(); \ } \ } while (0)
