
В коде часто встречаются проверки вида:
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)
