Неопределенное поведение (undefined behavior, UB) — это удивительная особенность некоторых языков программирования, позволяющая написать синтаксически корректную программу, работающую совершенно непредсказуемо при переносе ее с одной платформы на другую, изменении опций компиляции/интерпретации, и замене одного компилятора/интерпретатора другим. И главное — помимо синтаксической корректности, программа выглядит корректной семантически.
Состоит эта особенность в том, что в спецификации языка программирования сознательно не определяют поведение программы в каких-то особых условиях. Делается это из соображений производительности: не надо генерировать дополнительные инструкции с проверками. Или из соображений обеспечения гибкости при реализации каких-то фич. В спецификации пишут просто: «Если код делает что-то нехорошее, то поведение не определено». Например:
- Если обратиться по нулевому указателю, поведение не определено.
- Если дважды захватить блокировку в одном и том же потоке, поведение не определено.
- Если поделить на ноль, поведение не определено.
- Если прочитать неинициализированную память, поведение не определено.
- И так далее, и тому подобное.
Важно, что это «поведение не определено» означает, что произойти может что угодно: форматирование диска, ошибка компиляции, исключение, а может и все будет хорошо. Никаких гарантий не дается. Отсюда и происходят веселые, неожиданные и очень печальные в production-коде последствия.
И, конечно же, именно C и C++ наиболее печально известны своим неопределенным поведением. Однако надо понимать, что эта особенность присуща и другим языкам. Во многих языках можно найти какой-нибудь редкий особенный пример с неопределенным поведением. Но именно в C и C++ оно встречается при написании почти любой программы. Слишком много фич языка содержат пункты с неопределенным поведением.
И так, по каким же признакам можно заподозрить UB в программе и насколько неопределенное поведение действительно неопределенное?
Когда-то давно UB в коде могло повлечь действительно что угодно. Например, gcc 1.17
начинал запускать игрушечки.
Сегодня, если вы поделите что-то на ноль, подобного почти наверное не произойдет. Однако неприятности все же бывают разные:
- Для данной конкретной платформы и компилятора в документации сказано что именно произойдет, несмотря на страшные слова «undefined behavior» в стандарте. И все будет хорошо. Вы знаете что делаете. Никакой неопределенности. Все классно.
- UB при работе с памятью чаще всего заканчиваются ошибкой сегментации и получением прекрасного сигнала SIGSEGV от операционной системы. Программа падает.
- Программа работает и штатно завершается. Но дает разные или неадекватные результаты от запуска к запуску. Также результаты меняются от сборки к сборке при изменении опций компилятора или самого компилятора. Никаких генераторов случайных чисел вы не использовали.
- Программа ведет себя неправильно, несмотря на то, что в коде наставлено огромное множество проверок,
assert
'ов,try-catch
блоков, каждый из которых «подтверждает» что все корректно. В отладчике видно, что вычисления идут корректно, но совершенно внезапно все ломается. - Программа выполняет код, который в ней есть, но не вызывался. Отрабатывают ни разу не вызываемые функции.
- Компилятор «без причины» и без падения отказывается собирать код. Линковщик выдает «невозможные и бессмысленные» ошибки.
- Проверки в коде перестают исполняться. Под отладчиком видно, что исполнение не заходит внутрь веток
if
илиcatch
, хотя по значениям переменных заход должен быть выполнен. - Внезапный необоснованный вызов
std::terminate
. - Бесконечные циклы становятся конечными и наоборот.
С неопределенным поведением часто путают другие понятия.
- Еще одна страшная аббревиатура UB — неуточненное (unspecified) поведение. Стандарт не уточняет, что именно может произойти, но описывает варианты. Так, например, порядок вычисления аргументов функции — поведение неуточненное.
- Поведение, определяемое реализацией (implementation-defined) — надо смотреть документацию для вашей платформы и вашего компилятора.
- Ошибочное поведения (erroneous) — новинка C++26. Часть неопределенного поведения будет, возможно, переквалифицированна в эту категорию. Например, так поступили с чтением неинициализированных переменных. Разница с неопределенным — компилятору очень рекомендуется выдавать диагностики и запрещается выполнять умные оптимизации с неожиданными побочными эффектами.
Эта тройка намного лучше неопределенного, хотя и имеет с ним одну общую черту: программа, полагающаяся на любое из них, вообще говоря, непереносима.
Также выделяют два класса неопределенного поведения:
- Неопределенное поведение на уровне библиотеки (Library Undefined Behavior): Вы сделали что-то что не предусматривается конкретной библиотекой (в том числе и стандартной, но не всегда). Например, библиотека gmock под страхом неопределенного поведения не допускает донастраивать mock-объект после начала его использования.
- Неопределенное поведение на уровне языка (Language Undefined Behavior): Вы сделали что-то что фундаментально не определено спецификацией языка программирования. Например, разыменовани нулевой указатель.
Если вы столкнулись с первым — у вас проблемы, но если работает, то с очень большим шансом и продолжит работать пока вы не обновите библиотеку/не смените платформу. А побочные эффекты часто могут быть лишь локальными. Очень похоже на implementation defined поведение.
Если вы столкнулись со вторым — у вас большие проблемы. Код может перестать работать корректно совершенно внезапно при малейших изменениях. А также могут быть серьезные угрозы безопасности для пользователей вашего приложения.