Валидация программы.

Отчасти источником проблем с 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 (а также их плюсы и минусы):

  1. std::abort
    + хорошо взаимодействует с отладчиками — отладчики умеют показывать место, где упала программа. Например, linux может создать дамп своей памяти перед std::abort, и в отладчике можно посмотреть, что было перед падением программы.
    + заставляет людей чинить проблемы
    - неосторожный коммит может помешать работе всей команды
  2. Исключение
    - становится тяжелее писать exception-safe код
    - меняет поведение программы
    + легко тестировать
  3. Логирование
    + сохраняет поведение программы
    - могут проигнорировать или не заметить

На деле можно встретить каждую из этих трёх реализаций. В 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::vectoroperator[](size_t pos) и at(size_z pos). Обе функции возвращают pos-тый элемент вектора. При этом, у квадратных скобок будет узкий контракт — корректность индекса не проверяется, и вызов a[a.size() + 10] приведёт к UB. Если же at(size_t pos) подать некорректный индекс, то она бросит исключение. Для любого входа поведение определено, значит её контракт широкий.