Введение
Термин «неопределённое поведение» неоднократно встречался в этом цикле (выход за границы массива статья 518/519, переполнение знакового типа статья 512, разыменование нулевого указателя статья 519) — в этой статье разберём это понятие систематически: что именно оно означает с точки зрения стандарта языка, и почему понимание этой концепции критично важно для написания корректного C/C++ кода.
Концепция
Неопределённое поведение (UB) — категория действий, для которых стандарт C/C++ явно НЕ устанавливает никаких требований к поведению программы — это принципиально отличается от ошибки компиляции (явно обнаруживаемой и блокирующей сборку) или даже определённого, но платформенно-зависимого поведения (которое хотя бы предсказуемо для конкретной платформы) — для неопределённого поведения стандарт буквально разрешает компилятору сгенерировать программу с АБСОЛЮТНО любым поведением, включая (на практике) кажущуюся «нормальную» работу на одном компиляторе/уровне оптимизации, явный крах на другом, или, что особенно коварно, корректную работу при отладочной сборке без оптимизаций, но некорректную при оптимизированной production-сборке того же самого кода. Это критично важно понимать, потому что код с неопределённым поведением может «работать» в тестовом окружении разработчика чисто случайно, создавая ложное чувство уверенности в его корректности, и проявить реальную проблему только позже, на другой платформе, с другим компилятором, или после, казалось бы, не связанного изменения в совершенно другой части кода (которое могло повлиять на оптимизации компилятора для участка кода с неопределённым поведением).
Пример кода: Неопределённое поведение (undefined behavior)
#include <iostream>
void demonstrateUB_signedOverflow()
{
int maxInt = 2147483647; // INT_MAX для типичного 32-битного int
int overflowed = maxInt + 1; // ПЕРЕПОЛНЕНИЕ ЗНАКОВОГО ТИПА — неопределённое поведение!
// На практике МНОГИЕ компиляторы дадут -2147483648 ("обёртывание"),
// но СТАНДАРТ это НЕ гарантирует, и оптимизирующий компилятор технически
// может предположить, что переполнение НИКОГДА не происходит, и оптимизировать код СООТВЕТСТВЕННО,
// что иногда приводит к УДАЛЕНИЮ проверок переполнения, написанных программистом!
std::cout << overflowed << std::endl;
}
void demonstrateUB_outOfBounds()
{
int array[5] = {1, 2, 3, 4, 5};
std::cout << array[10] << std::endl; // ВЫХОД ЗА ГРАНИЦЫ — неопределённое поведение (статья 518)
// Может вывести случайное число, привести к крашу, или (теоретически) сделать что угодно ещё
}
void demonstrateUB_uninitializedRead()
{
int x; // НЕ инициализировано — содержит "мусорное", неопределённое значение
std::cout << x << std::endl; // ЧТЕНИЕ неинициализированной переменной — неопределённое поведение
}
// Демонстрация того, ПОЧЕМУ компилятор может "сломать" код с UB при оптимизации
bool checkOverflow(int a, int b)
{
int sum = a + b; // потенциальное переполнение, если a и b близки к границам диапазона
return sum < a; // НАИВНАЯ попытка проверить переполнение ПОСЛЕ его возможного возникновения
// Оптимизирующий компилятор, ЗНАЯ, что signed overflow — UB (и, следовательно,
// "не может произойти" в корректной программе согласно стандарту), может ЛОГИЧЕСКИ
// заключить, что (a + b) ВСЕГДА >= a для положительного b, и УДАЛИТЬ эту проверку целиком,
// ВСЕГДА возвращая false, независимо от реального переполнения!
}
Пояснения к коду
demonstrateUB_signedOverflow показывает классический пример — переполнение знакового целочисленного типа технически является UB согласно стандарту (хотя беззнаковые типы, статья 512, корректно и предсказуемо «оборачиваются» по модулю при переполнении, что ЯВНО определено стандартом именно для них), и важное, неочевидное следствие этого — компилятор, видя выражение, которое МОГЛО БЫ переполниться, формально имеет право предположить (поскольку UB «не может произойти» в корректной, согласно стандарту, программе), что переполнение никогда реально не случится, и на основе этого предположения выполнить агрессивные оптимизации, иногда полностью устраняющие код, написанный программистом именно для проверки этого самого переполнения (как явно показывает checkOverflow). Примеры с выходом за границы массива и чтением неинициализированной переменной — другие классические, часто встречающиеся категории UB, каждая способная проявиться абсолютно непредсказуемым образом — от кажущегося «нормальным» результата (что особенно опасно, создавая ложное ощущение работоспособности кода) до немедленного краха программы.
Подводные камни
- Тестирование кода с неопределённым поведением и заключение о его «корректности» на основании того, что тесты прошли успешно на конкретной машине разработчика с конкретным компилятором и настройками оптимизации — как явно показывает пример с
checkOverflow, код с UB может вести себя совершенно по-разному при изменении компилятора, его версии, уровня оптимизации, или даже, казалось бы, несвязанных изменений в другом месте кода, влияющих на решения оптимизатора, и успешное прохождение тестов в одном конкретном окружении НЕ является доказательством отсутствия UB и, соответственно, не гарантирует корректной работы во всех других окружениях. - Использование инструментов статического анализа (clang-tidy, cppcheck, статья 450 DevOps-цикла) и санитайзеров (AddressSanitizer, UndefinedBehaviorSanitizer) недостаточно регулярно или не на достаточно широком наборе тестовых сценариев — эти инструменты способны обнаружить значительную часть распространённых случаев UB (выход за границы, использование неинициализированной памяти, некоторые случаи переполнения), но они не являются полной, абсолютной гарантией отсутствия UB во всём коде, и их регулярное, систематическое использование (интегрированное в CI, как разбиралось в DevOps-цикле) — важная, но не единственная мера предосторожности против UB.
- Полагание на «интуитивно ожидаемое» поведение для случаев, формально являющихся неопределённым поведением, особенно когда конкретный, используемый сейчас компилятор демонстрирует это «интуитивно ожидаемое» поведение на практике — это создаёт код, формально некорректный согласно стандарту, который может перестать работать ожидаемым образом при обновлении компилятора, изменении настроек оптимизации, или переносе на другую платформу, и явное избегание конструкций с UB (а не полагание на «случайно работающее» поведение конкретного компилятора) — единственный по-настоящему надёжный подход к написанию корректного, переносимого C/C++ кода.
- Недооценка серьёзности UB как «теоретической», не имеющей реального практического значения проблемы — реальные, многократно документированные случаи серьёзных уязвимостей безопасности и труднодиагностируемых производственных сбоев в широко используемом программном обеспечении были прослежены именно до случаев неопределённого поведения, неожиданно «активированного» изменением компилятора или уровня оптимизации, и это далеко не абстрактная, теоретическая забота, а реальный, практически значимый источник серьёзных проблем в production-коде, заслуживающий серьёзного, систематического внимания при написании и проверке C/C++ кода.