Undefined behaviour в C++: как выстрелить себе в ногу
Программа с UB может годами работать в отладке и падать в проде после смены компилятора. Разбираем, почему это происходит, где чаще всего прячется UB и как его ловить.

Вы написали функцию, которая проверяет переполнение через if (x + 1 < x). В отладочной сборке всё срабатывает. Собрали релиз с -O2, и проверка молча исчезла из бинарника — компилятор удалил её как заведомо ложную. Никакой ошибки, никакого предупреждения по умолчанию. Просто другой машинный код и переполнение, которое теперь никто не ловит. Это undefined behaviour, и оно опаснее любого краша, потому что не обязано ронять программу — оно имеет право делать что угодно, включая «работать правильно» ровно до дня демонстрации заказчику.
Материал для тех, кто уже пишет на C++ (стандарт C++17 и новее) и хочет понимать, почему компилятор ведёт себя не так, как ожидается. Разбираем механизм: что стандарт называет UB, почему оптимизатор это использует и где UB прячется чаще всего. Полного каталога всех форм UB здесь нет — их сотни; берём те, на которые натыкаются в реальном коде.
Что такое UB и почему компилятор им пользуется
Стандарт C++ делит поведение на несколько категорий. Их стоит различать, потому что реакция инструментов на них разная.
- Undefined behaviour (UB) — стандарт не накладывает никаких требований. Компилятор вправе сгенерировать любой код, оптимизатор — вырезать целые ветки.
- Unspecified behaviour — есть несколько допустимых вариантов, компилятор выбирает один и не обязан документировать. Пример — порядок вычисления аргументов функции.
- Implementation-defined — как unspecified, но реализация обязана задокументировать выбор. Пример — размер
int.
Ключевая идея, которую многие упускают: оптимизатор исходит из презумпции, что UB не происходит. Раз программист не имеет права переполнять знаковое int, компилятор считает, что x + 1 < x для знакового x — всегда ложь, и удаляет проверку. Формально он прав. Практически вы получили дыру.
UB — это не «программа упадёт». Это «программа больше не обязана делать ничего осмысленного, включая то, что вы наблюдаете в отладчике».
Знаковое и беззнаковое переполнение — разные миры
Переполнение беззнаковых типов определено: арифметика по модулю 2^N. Переполнение знаковых — UB. Отсюда и исчезновение проверки выше. Правильная проверка переполнения сложения для знаковых типов — через __builtin_add_overflow в GCC/Clang или через приведение к более широкому типу с последующей проверкой диапазона. В C++20 для беззнаковых можно опираться на гарантированное поведение по модулю.
Где UB прячется чаще всего
Ниже — категории, которые дают львиную долю реальных инцидентов. Внутри каждой — не единственный, а типовой случай.
1. Обращение к памяти вне границ
Чтение или запись за пределами массива, разыменование nullptr, доступ к освобождённой памяти (use-after-free), двойное delete. Само по себе одно из самых распространённых и самых разрушительных семейств UB, потому что часто «случайно работает»: соседняя память ещё не затёрта.
// UB: чтение за границей вектора
#include <vector>
int sum_first_four(const std::vector<int>& data) {
int total = 0;
for (int i = 0; i <= 4; ++i) // ошибка: <= вместо <
total += data[i]; // operator[] границы не проверяет
return total;
}
// Замена data[i] на data.at(i) даёт исключение вместо UB
2. Разыменование нулевого или невалидного указателя
Оптимизатор использует это агрессивно. Если вы разыменовали указатель, а потом сравнили его с nullptr, компилятор вправе удалить сравнение: раз разыменование уже случилось, указатель «не может» быть нулевым.
3. Неинициализированное чтение
Чтение автоматической переменной до присваивания — UB. В отладочной сборке там часто нули, в релизе — мусор со стека или вовсе значение, которое компилятор считает «любым удобным».
4. Нарушение времени жизни объекта
Возврат ссылки или указателя на локальную переменную, висячая ссылка после std::move, доступ к элементу вектора после push_back, вызвавшего реаллокацию. Последнее особенно коварно:
#include <vector>
std::vector<int> nums = {1, 2, 3};
int& first = nums[0]; // ссылка на текущий буфер
nums.push_back(4); // возможна реаллокация -> first висячая
first = 10; // UB: пишем в освобождённую память
5. Гонки данных
Доступ к одной переменной из двух потоков, где хотя бы один пишет, без синхронизации — UB, даже если «на этой машине атомарно». Решение — std::atomic или мьютекс, а не надежда на архитектуру процессора.
6. Нарушение строгого алиасинга
Обращение к объекту одного типа через указатель несовместимого типа. Классика — «переинтерпретировать» float как int через reinterpret_cast и читать. Легальный способ побитовой переинтерпретации в C++20 — std::bit_cast, до него — std::memcpy.
7. Сдвиг на некорректное число бит
Сдвиг на число бит, большее или равное ширине типа (x << 32 для 32-битного int), а также сдвиг отрицательного знакового значения влево — UB.
8. Деление на ноль и INT_MIN / -1
Целочисленное деление на ноль — UB. И отдельно: INT_MIN / -1 тоже UB, потому что результат не помещается в тип.
Таблица: категории, чем грозит, чем ловить
| Категория UB | Как проявляется в проде | Инструмент обнаружения |
|---|---|---|
| Выход за границы, use-after-free | Порча памяти, редкие падения, RCE-уязвимости | AddressSanitizer (ASan) |
| Неинициализированное чтение | «Плавающие» результаты, зависят от сборки | MemorySanitizer (MSan, Clang) |
| Знаковое переполнение, плохой сдвиг | Исчезнувшие проверки, неверная арифметика | UndefinedBehaviorSanitizer (UBSan) |
| Гонки данных | Редкие непроизводимые баги под нагрузкой | ThreadSanitizer (TSan) |
| Нарушение алиасинга | Оптимизатор переупорядочил чтения/записи | UBSan частично, ревью + -fno-strict-aliasing как обход |
Чем ловить: санитайзеры и предупреждения
Санитайзеры — инструментация во время сборки, которая проверяет операции в рантайме. Они не бесплатны по скорости, поэтому их гоняют на тестах и в CI, а не в релизе. Базовый набор флагов для GCC и Clang:
# Адресный + UB санитайзер, отладочные символы
g++ -std=c++20 -g -O1 \
-fsanitize=address,undefined \
-fno-omit-frame-pointer \
main.cpp -o app
# Отдельно потоковый (несовместим с ASan в одной сборке)
g++ -std=c++20 -g -O1 -fsanitize=thread main.cpp -o app_tsan
ASan и TSan нельзя включать в одной сборке одновременно — делайте два прогона. MSan доступен только в Clang и требует, чтобы вся кодовая база, включая стандартную библиотеку, была инструментирована, поэтому его внедрять сложнее.
Предупреждения компилятора — первая линия обороны, и по умолчанию они выключены. Минимум для новых проектов:
-Wall -Wextra -Wpedantic -Werror
Флаг -Werror превращает предупреждения в ошибки сборки. Жёстко, но заставляет чинить сразу, а не копить.
Что делать дальше
Порядок внедрения, который окупается быстрее всего:
- Включите
-Wall -Wextraи разберите текущий поток предупреждений. Это ноль по стоимости и часто ловит половину проблем. - Добавьте в CI отдельную сборку с
-fsanitize=address,undefinedи прогоняйте на ней тесты. UB, которое воспроизводится в тестах, санитайзер покажет с точным стеком и строкой. - Подключите статический анализатор —
clang-tidyили встроенный анализатор компилятора. Он ловит часть UB без запуска кода. - Замените ручные
reinterpret_castдля переинтерпретации типов наstd::bit_cast(C++20) илиstd::memcpy, аoperator[]на границах данных — на.at()в местах, где производительность не критична.
Санитайзеры не доказывают отсутствие UB — они находят его на тех путях, которые вы прогнали. Что реально измерять в проде: частоту крашей после смены версии компилятора и разницу поведения между -O0 и -O2. Если оптимизации меняют логику — где-то прячется UB.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: cppreference — Undefined behavior, Clang — Controlling Code Generation (Sanitizers)
Частые вопросы
Почему код с UB работает в отладке и ломается в релизе?
У меня другой компилятор — MSVC вместо GCC. Санитайзеры работают?
Санитайзер ничего не нашёл — значит, UB в коде нет?
Можно ли оставить санитайзеры включёнными в продакшене?
Как обойти нарушение строгого алиасинга, если нужно переинтерпретировать байты?
Флаг -Werror ломает мне всю сборку из-за предупреждений в чужих заголовках. Что делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.