Валидация программы.
Отчасти источником проблем с UB является стандарт, который написан на бумаге, в связи с чем программист не знает, то, что он вообще пишет, — это UB или нет. Впрочем, на текущий момент с этой ситуацией живётся намного проще.
Ключ компиляции -fsanitize.
У компиляторов есть ключи, которые вставляют дополнительные проверки. Например, -fsanitize=undefined. Если его поставить, то при запуске на этапе вызова последней строки
bool test(int a) {
return a + 1 < a;
}
int main() {
test(std::numeric_limits<int>::max());
}
вам в stderr
напишут, что у вас integer overflow. Почему это происходит на этапе исполнения? Потому что все предупреждения стоят определённую цену из времени компиляции. Скорее всего, у GCC есть ключ, который включает статический анализатор, но тогда программа будет компилироваться бесконечно долго.
Другой хороший пример:
int* p = new int(42);
delete p;
*p = 43;
Если вы скомпилируете это с -fsanitize=address, то при исполнении вам выдадут, что в третьей строке вы пишете туда, куда писать нельзя. А именно, что во второй строке вы это освобождали, а в первой — выделяли. Т.е. компилятор вставил в программу необходимые проверки, чтобы это ловить.
Работает ключ -fsanitize=address следующим образом: создаётся табличка, в которой указано, какие байты где выделены, когда освобождены и т.п. При этом, когда вы обращаетесь к памяти, в эту табличку смотрят и проверяют, легально ли вы сделали. Это табличка называется shadow-байтами. Более того, когда вы что-то освободили, а после этого снова выделяете, обычно аллокатор даёт вам то, что вы только что освободили (пока оно в кэше). А при -fsanitize=address эта память находится в карантине и только потом переиспользуется, чтобы отлавливать ситуации вида
int* p = new int(42);
delete p;
int* q = new int(43); // Стандартный аллокатор положит `*q` туда, где было `*p`.
*p = 44;
Вдобавок вокруг аллоцированных блоков памяти вставляется красная зона, чтобы если вы чуть-чуть промахнулись по адресу, вы не попали куда-то, где могут быть корректные данные, а попали чётко туда, куда нельзя.
Ещё -fsanitize=address включает в себя проверку на утечки памяти — когда вы что-то выделяете и не освобождаете.
-fsanitize=address и -fsanitize=undefined можно использовать совершенно свободно. Ещё есть memory- и thread-sanitizer'ы. Первый находит неинициализированную память, второй — состояние гонки. Их использовать гораздо труднее. Почему? В случае address-sanitizer'ов можно скомпилировать часть программы с ним, а часть — без. В случае с memory же, у вас могут быть ложные срабатывания, ведь в shadow-памяти не будет записей из той части программы, которая скомпилирована без sanitizer'а. А этой частью может быть, ну, например, стандартная библиотека. И аналогично с thread-sanitizer'ами — они также тречат записи и чтения.
valgrind.
Ещё можно использовать утилиту valgrind, который проверяет программы постфактум. Она умеет находить почти те же самые ошибки (address, memory), и при этом не требует компилировать программу со специальными ключами. За счёт чего она работает?
А она содержит JIT-декомпилятор x86. Понятно, что работает это капец медленно (20 раз против 2 для ключей), но зато нет никаких проблем с memory-sanitizer'ом. Только не забудьте компилировать вашу программу с ключом -g, чтобы вы видели, на каких строках кода ошибки. При этом если вы не хотите давать пользователю дебажные символы, просто отправьте их в отдельный файл. И на сервер залейте. Подробнее об этом — ниже.
valgrind, к сожалению, немного меньше ошибок видит просто потому, что, например, не может добавить песочка между выделенными данными. И поэтому же valgrind не может проверять undefined и thread. В случае undefined он, например, не знает, из какого языка взялась инструкция add
, и обладает ли она неопределённым поведением в нём, а в случае thread — не знает, многопоточная ли та инструкция mov
, которую он видит, или нет.
Проверка предусловий стандартной библиотеки.
valgrind и sanitizer'ы — это не единственное, что вы можете. И, что более важно, они не всегда вам помогут.
Рассмотрим вот что:
std::list<int> lst;
lst.push_back(1);
auto it = lst.end();
it++;
std::cout << *it << std::endl;
На большинстве реализаций стандартной библиотеки тут вы получите 1
, потому что в большинстве реализаций std::list
— кольцевой список. При этом с указателями всё хорошо, вы обращаетесь к корректной памяти, а значит sanitizer'ы не найдут ошибок. Но видно же, что программа некорректна. Чтобы такое тоже проверять, есть флаг -D_GLIBCXX_DEBUG, который как раз проверяет инвалидацию итераторов, разыменование и инкремент past-to-end и все остальные нарушения контрактов.
Понятно, что это даже асимптотически очень медленно — например, бинарный поиск работает за линейное время (потому что проверяет, является ли переданный ему массив отсортированным) — однако очень полезно.
assert
.
Мы уже можем проверять корректность на двух уровнях — уровне указателей и уровне контрактов. А есть уровень ещё выше. Например, мы пишем свой целочисленный квадратный корень. И мы хотим, чтобы пользователь не полагался на какое-то конкретное поведение, если передать отрицательное значение. Поэтому есть assert
. Это такой макрос из заголовка <cassert>
, который в зависимости от настроек может делать ничего или писать вам ошибку в программе в случае невыполнения какого-то условия. Очень советуется её писать везде, где вы полагаетесь на какие-то условия у себя в голове. Если вы полагаетесь на то, что next->prev == this
, напишите на это assert
. Если вы полагаетесь на m >= n
, напишите assert
. Понятно, особого фанатизма не требуется, но тем не менее делайте это, иначе вы где-то можете получить мусор из-за бага, передать этот мусор в функцию, получить мусор там, в результате чего потратить на отладку 3 дня вместо нескольких минут.
Способы реализации assert
(а также их плюсы и минусы):
std::abort
+
хорошо взаимодействует с отладчиками — отладчики умеют показывать место, где упала программа. Например, linux может создать дамп своей памяти передstd::abort
, и в отладчике можно посмотреть, что было перед падением программы.
+
заставляет людей чинить проблемы
-
неосторожный коммит может помешать работе всей команды- Исключение
-
становится тяжелее писать exception-safe код
-
меняет поведение программы
+
легко тестировать - Логирование
+
сохраняет поведение программы
-
могут проигнорировать или не заметить
На деле можно встретить каждую из этих трёх реализаций.
В C++ assert
реализован первым способом.
Неправильные практики использования assert
.
FILE* f = fopen("1.txt", "r");
assert(f != NULL);
Это вполне штатная ситуация, если файл не открылся, не надо проверять при помощи assert
что-то штатное.
Второе:
assert(fclose(f) == 0);
Тут ещё хуже — когда вы отключите assert
'ы, они отключатся полностью, и fclose
не выполнится.
То, во что раскрывается assert
, зависит от макроса NDEBUG
. Если он задан, то assert
'ы исчезают. Поэтому, если вам нужно написать проверку чего-то сложного (скажем, вы хотите проверить, что значение, посчитанное разными способами, одинаковое), то напишите #ifndef NDEBUG
и ехайте проверять всё, что вашей душе угодно.
Cто́ит ли давать программу с assert
'ами пользователю? Если вы задались этим вопросом, скорее всего у вас недостаточно assert
'ов в программе, ведь когда их достаточно, программа замедляется раза в 2.
Static asserts
Еще есть static_assert
, который используется для выполнения проверок во время компиляции. Если условие внутри static_assert
не выполняется, то программа просто не компилируется.
#include <cstdio>
template <typename T, size_t Size>
class NonEmptyContainer {
static_assert(Size > 0, "Size is 0");
T data_[Size];
};
int main() {
NonEmptyContainer<int, 3> a1; // ок
static_assert(sizeof(a1) == sizeof(int) * 3); // ок
NonEmptyContainer<long long, 10> a2; // ок
static_assert(sizeof(a2) == sizeof(long long) * 10); // ок
NonEmptyContainer<int, 0> a2; // ошибка компиляции
}
Wide/narrow контракты
Функции могут иметь широкий (wide) или узкий (narrow) контракт. Если поведение функции определено для любых входных данных, то считается, что у неё широкий контракт. Если у функции существуют входные данные, приводящие к UB, то её контракт узкий.
Рассмотрим, к примеру, две функции класса std::vector
— operator[](size_t pos)
и at(size_z pos)
. Обе функции возвращают pos
-тый элемент вектора. При этом, у квадратных скобок будет узкий контракт — корректность индекса не проверяется, и вызов a[a.size() + 10]
приведёт к UB. Если же at(size_t pos)
подать некорректный индекс, то она бросит исключение. Для любого входа поведение определено, значит её контракт широкий.