Неопределённое поведение (undefined behavior, UB) — ситуация, для которой стандарт языка не задаёт никаких требований к тому, что произойдёт. Не «результат зависит от платформы», а «дальше может быть что угодно».

UB — не способ получить случайное число и не особый режим арифметики. Это ошибка программы.

Не из лекции

Разделы про санитайзеры и про соседние понятия (unspecified, implementation-defined) в материалах занятия не разбирались. Остальное соответствует лонгриду, где UB отведён отдельный раздел.

Чем это отличается от «непредсказуемого результата»

Если программа содержит UB, компилятор вправе считать, что этой ситуации не бывает, — и оптимизировать исходя из этого. Поэтому последствия не ограничиваются странным значением в одной переменной: может измениться логика кода вокруг, исчезнуть проверка, а программа при этом успешно пройдёт тесты.

Практический вывод: UB нельзя проверить экспериментом. «У меня работает» — не аргумент; в другой сборке или с другим уровнем оптимизации поведение изменится.

Что вызывает UB чаще всего

На старте курса встречаются:

  • чтение неинициализированной переменной — см. Инициализация переменных;
  • переполнение знакового целого (у беззнаковых поведение, наоборот, определено — см. Беззнаковые типы);
  • целочисленное деление на ноль — см. Целочисленное деление и остаток;
  • деление минимального знакового значения на −1: результат не представим в том же типе, поэтому проверки одного лишь b != 0 для произвольных знаковых данных недостаточно;
  • сдвиг на отрицательное число позиций или на количество, не меньшее ширины типа левого операнда после продвижения, — см. Побитовые операции;
  • выход за границы массива;
  • static_cast<int> из double, значение которого не влезает в диапазон int.

Дальше добавятся разыменование нулевого или висячего указателя, обращение к освобождённой памяти, гонки данных.

Соседние понятия

Стандарт различает несколько степеней «неопределённости», и путать их не стоит:

  • undefined — гарантий нет вообще;
  • unspecified — из нескольких вариантов реализация выбирает любой, но перечень вариантов известен (например, порядок вычисления аргументов функции);
  • implementation-defined — реализация выбирает и обязана задокументировать свой выбор (например, размер int).

Как его обнаружить

Обычный запуск g++ main.cpp -o main && ./main про UB ничего не скажет: программа соберётся и что-нибудь напечатает. Когда результат странный или нестабильный, помогают дополнительные флаги — см. Компиляция и сборка:

g++ -g -fsanitize=undefined,address main.cpp -o main && ./main

Санитайзер подставляет проверки во время выполнения и печатает файл и строку, где произошло нарушение. Такую сборку делают для поиска ошибки, а не постоянно: программа работает медленнее.

Часть проблем видна и раньше — по предупреждениям компилятора с -Wall -Wextra.

Частые заблуждения

  • «UB — это когда программа падает». Падение — самый удачный исход; опаснее, когда она продолжает работать.
  • «Я запустил, получилось −294967296, значит переполнение так и работает». Увиденное однажды число ничего не доказывает: при другой оптимизации или на другой платформе результат будет иным.
  • «Floating point exception после деления на ноль — это исключение C++». Нет: так конкретная среда сообщает о сбое. К дробным числам и к механизму исключений языка сообщение отношения не имеет.
  • «Раз тесты прошли, UB нет». Тесты проходят и на сломанном коде.
  • «Отключу предупреждения, и станет чисто». Предупреждение — часто единственный признак UB, который вы увидите.

Углубление — L02

Ещё один источник UB — пустой цикл:

while (x);

Это неопределённое поведение, если x не меняет своего результата и не имеет побочных действий. То есть это не «честное зависание», на которое можно рассчитывать. См. Циклы.

Углубление — L03

С указателями и динамической памятью список источников UB пополнился:

  • чтение неинициализированного указателя и разыменование через него — см. Указатель;
  • разыменование нулевого указателя: по стандарту по нулевому адресу ничего не лежит;
  • чтение или запись через висячий указатель — как на объект, чей блок закончился, так и на объект после delete (use after free);
  • delete для памяти, выделенной не через new, — см. Динамическая память; удаление одного объекта дважды;
  • разыменование указателя, полученного арифметикой на отдельной переменной, — см. Арифметика указателей;
  • переполнение при умножении int на int, которое лечится приведением одного операнда к int64_t через static_cast.

Не из лекции

Use after free, выход за пределы блока и утечки ловит -fsanitize=address: сообщения heap-use-after-free, stack-use-after-scope и LeakSanitizer: detected memory leaks (GCC 16.2).

Углубление — L04

Не из лекции

В лонгриде L04 слова «UB» нет, но с массивами связаны два источника:

  • delete вместо delete[] для памяти из new[] — GCC 16.2 предупреждает -Wmismatched-new-delete, AddressSanitizer сообщает alloc-dealloc-mismatch, см. Динамический массив;
  • выход за границы массива, уже упомянутый выше, включая обращение к a[N] в массиве из N элементов.

Массив переменной длины int a[n] — не UB, а расширение компилятора, которого нет в стандарте C++.

Углубление — L05

Новый источник UB: функция с не-void возвращаемым типом дошла до конца тела без return.

float Max(float x, float y) {
  if (x < y) {
    return y;
  }
}  // UB if execution reaches this point
 
Max(10, 4);

Результатом не будет ни ноль, ни «последнее вычисленное число». На лекции программа упала с Illegal instruction, но и на падение рассчитывать нельзя. Исключение одно — main: конец её тела равносилен return 0;. См. Функция.

Ещё одно — висячая ссылка: возврат ссылки на локальную переменную, см. Висячий указатель.

Не из лекции

GCC 16.2 с -Wall: control reaches end of non-void function [-Wreturn-type]; программа завершилась сигналом SIGILL (код 132) — GCC ставит в такое место инструкцию-ловушку.