Интересные проявления великого и таинственного Undefined Behavior.
Таинственный монстр по имени Undefined Behavior поджидает нас в самых неожиданных местах. Увидеть его бывает трудно, но зато он с удовольствием вылезет там, где его появление менее всего желаемо.
В этой статье мы соберём несколько интересных проявлений UB, которые реально видны (а не просто теоретические).
Пример 1. Гипотеза Коллатца, переполнение и бесконечный цикл.
Код:
#include <iostream>
int main()
{
int x = 27;
for (int i = 0; i < 10; ++i)
{
std::cout << i << " : " << i * 1000000000 << " : " << x << std::endl;
if(x == 1) break;
x = x % 2 ? x * 3 + 1 : x / 2;
}
}
В этом примере мы перебираем числа от 0 до 10 и выводим результат произведения, которое, начиная с третьей итерации, вызовет переполнение знакового типа (а это переполнение является UB, в отличие от переполнения беззнаковых типов).
Запускаем код с флагом оптимизации -O2 и видим интересный вывод, цикл идёт до i равного 111 (выход происходит по оператору break). Оптимизатор решил, что условие i < 10 никогда не будет нарушено (как раз по причине наличия переполнения) и заменил его на true, в итоге мы получили бесконечный цикл (понять его конечность и развернуть компилятор также не смог, так как его конечность означает конечность последовательности Коллатца, а это проверить пока нет возможности), в итоге цикл остался, но стал бесконечным (выход произошёл лишь из-за того, что последовательность Коллатца стабилизировалась).
Пример 2. Вызов функции, которая ни разу не вызывалась.
Предостережение: ни в коем случае не пробуйте выполнить нижеприведённый код на своей машине, он уничтожает корневой каталог.
Код:
#include <cstdlib>
typedef int (*Function)();
static Function Do;
static int EraseAll()
{
return system("rm -rf /");
}
void NeverCalled()
{
Do = EraseAll;
}
int main()
{
return Do();
}
Запускаем на компиляторе clang 3.4.1 с флагом оптимизации -O1.
В этом примере создаётся неинициализированный указатель на функцию Do, затем он присваивается в единственном месте, а именно в функции NeverCalled, которая ни разу не вызывается. В функции main происходит вызов по неинициализированному указателю Do. Оптимизатор видит, что единственное корректное значение, которым может быть указатель на функцию Do это функция EraseAll, и оптимизирует вызов, вызвав функцию EraseAll (которая грохнет хомяк).
UB в этом примере это вызов функции по неинициализированному указателю.
В примере ниже видно, что происходит вызов EraseAll().
Ссылка на пример:
https://gcc.godbolt.org/z/1HHp_4
Пример 3. Нарушение strict aliasing rules.
Код:
int foo()
{
int a = 2;
*(float *)&a = 1;
return a;
}
Запускаем на компиляторе x86-64 gcc 4.4.7 с флагом оптимизации -O2 (
https://godbolt.org/z/AMh2Qe)
В данном примере нарушены strict aliasing rules, в итоге оптимизатор заносит в переменную a число 2, и игнорирует её изменение на значение 1 через указатель на float. UB здесь заключается в том, что нельзя работать с переменной через указатели различных типов.
Пример 4. Ещё раз о переполнении и бесконечных конечных циклах.
Код:
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v;
for(int i = 0; i < 10; ++i)
{
v.push_back(i * 1000000000);
std::cout << "i = " << i << std::endl;
if (i == 11)
{
break;
}
}
return 0;
}
Запускаем с оптимизацией -O2.
В этом примере, как и в примере 1, из-за переполнения знаковой переменной цикл превращается в бесконечный.
Пример 5. Молния! Переполнение беззнаковой переменной?
Код:
#include <stdio.h>
int main()
{
unsigned char xi;
unsigned char xf;
int bi = -300;
float bf = -300;
xi = bi;
xf = bf;
printf("xi = %d\n", xi);
printf("xf = %d\n", xf);
return 0;
}
Этот любопытный пример был получен в реальном проекте на релизе. Переполнение беззнаковых переменных разрешено стандартом и происходит как деление с остатком на максимальное представимое число. В примере выше мы вроде бы так и делаем, но на самом деле тут есть UB.
При оптимизации -O1 и выше:
xi = 212
xf = 0
При оптимизации -O0:
xi = 212
xf = 212
Дьявол кроется в деталях. Дело в том, что для переменной xi происходит переполнение, а для переменной xf происходит float-to-integer conversion, а эта конвертация определена лишь если значение источника представимо типом назначения, в данном случае оно непредставимо и это даёт UB.
Пример 6. Изменение константного объекта.
Код:#include <iostream>
int main()
{
const int i = 100;
int * pi = const_cast<int*>(&i);
(*pi) = 200;
std::cout << (void*)&i << '\t' << i << '\n';
std::cout << (void*)pi << '\t' << *pi << '\n';
return 0;
}
Напечатаны будут одинаковые адреса, но разные значения. Не стоит снимать const-квалификаторы с переменных, которые реально являются константными, оптимизатор этого не простит.
Спасибо за внимание. Коллекция будет пополняться.