Языки программирования C и C++ широко используются в индустрии разработки программного обеспечения благодаря своей высокой производительности и гибкости. Однако за эти преимущества приходится платить определённой ценой, одной из которой является существование большого количества случаев, описываемых как неявное поведение или undefined behavior (UB). Этот феномен играет важную роль в оптимизации компиляторов, но в то же время может вызывать нежелательные последствия для программ безопасности и корректности. Неявное поведение — это ситуации, при которых стандарты языков не регламентируют точное поведение программы при определённых ошибочных или специфических условиях. Такие случаи могут возникать, например, при целочисленном переполнении со знаком, выходе за пределы массивов, использованием неинициализированных переменных, смещениях сдвигов на большее количество бит, чем ширина типа, и так далее.
Ключевой особенностью UB является отсутствие обязательного для всех исполнений результата, что позволяет компиляторам предполагать, что эти ситуации не происходят, и применять более агрессивные оптимизации. Исторические причины появления UB связаны с задачей обеспечения максимальной эффективности кода на различных архитектурах процессоров. Когда создавались языки C и C++, важно было, чтобы операции можно было свести к одной машинной инструкции на максимально широком диапазоне аппаратного обеспечения. В особенности это было актуально в эпоху относительно простых и медленных процессоров, когда даже небольшие дополнительные затраты на обработку исключений снижали общую производительность. Для достижения этого компиляторы получили свободу трактовать определённые ситуации как неограниченно вариабельные — то есть undefined behavior.
Один из классических примеров UB — ситуация с целочисленным переполнением знаковых типов. Стандарт C/C++ определяет, что при использовании знаковых целых типов поведение в случае переполнения не определено. Это позволяет компиляторам, например, преобразовывать выражения вида a + b > a в условие b > 0 без дополнительных проверок. Для программиста же такая оптимизация может ввести в заблуждение, особенно если он рассчитывал на логическую проверку переполнения через подобные выражения. Использование UB предоставляет компиляторам пространство для оптимизаций, которые в противном случае были бы невозможны.
Сейчас некоторые компиляторы, как LLVM, достаточно агрессивно применяют подобные предположения, что ведёт как к незначительному, так и к заметному улучшению производительности кода на некоторых архитектурах. Вместе с тем, как показывают исследования, в среднем конечное ускорение программ зачастую оказывается минимальным или вовсе отсутствует. В случаях, когда производительность снижается, её часто можно вернуть за счёт тонкой настройки оптимизаций или использования дополнительных методов, например, link-time optimization (LTO). Одним из примеров, где UB напрямую влияет на производительность, является оптимизация циклов с обходом массивов и приведением типов индексов. В 64-битных системах зачастую переменная цикла объявляется как int, тогда как указатель на массив имеет 64-битное представление.
Это требует знакового расширения при вычислении адреса, что снижает производительность из-за дополнительных инструкций. Изменение типа переменной цикла на 64-битный long позволяет убрать эти преобразования, существенно улучшая работу кода. Такая трансформация юридически корректна только потому, что переполнение по знаковому целочисленному типу считается UB, и компилятор может предполагать, что переполнения не будет. Однако использование UB таит в себе существенные риски, особенно с точки зрения безопасности. Отсутствие строгого контроля за такими ошибками ведёт к тому, что реальные программы могут содержать баги, которые в определённых условиях приводят к непредсказуемому поведению.
Один из ярких примеров — код из ядра операционной системы Linux, где компилятор, исходя из UB, оптимизировал удаление проверки на NULL для указателя. Это привело к тому, что условие if (!tun) стало считаться всегда ложным, что создало потенциальную уязвимость для атак. В ответ на подобные проблемы разработчики компиляторов и сообществ создали специальные флаги и опции, позволяющие отключать или смягчать агрессивные предположения о UB. При использовании таких флагов можно, например, сохранить проверки null-указателей, что уменьшает возможность возникновения уязвимостей. Однако такие меры не являются универсальным решением, поскольку поведение программ при возникновении UB всё равно остаётся непредсказуемым.
Рост внимания к безопасности и стабильности программ привел к разработке различных инструментов и методологий для обнаружения и предотвращения UB на этапе компиляции или тестирования. Среди них — статические анализаторы кода, специализированные санитайзеры (например, Undefined Behavior Sanitizer), а также предложения в стандартах языка об инициализации локальных переменных по умолчанию, что снижает риск появления неопределённых значений. В современных условиях наблюдается явное противоречие между желанием добиться максимальной производительности и необходимостью гарантировать безопасность и корректность программ. На практике многие проекты идут на компромисс, тщательно выбирая уровень агрессивности оптимизаций и одновременно уделяя внимание инструментам контроля и тестирования на наличие UB. Исследования, проведённые в последние годы, демонстрируют, что хотя UB предоставляет инструмент для оптимизаций, реальные выигрыши по производительности зачастую невелики в масштабных приложениях.