Многие считают, что неопределённое поведение программы возникает из-за грубых ошибок (например, запись за границы массива) или на неадекватных конструкциях (например, i = i++ + ++i). Поэтому для многих является неожиданностью, когда неопределенное поведение вдруг проявляет себя во вполне привычном и ничем не настораживающем коде. Рассмотрим один из таких примеров. Программируя на C/C++ никогда нельзя терять бдительность. Ад ближе чем кажется.
Описание ошибки
Я давненько не поднимал тему 64-битных ошибок. Тряхну стариной. В данном случае неопределённое проведение будет проявлять себя в 64-битной программе.
Рассмотрим некорректный синтетический пример кода.
size_t Count = 1024*1024*1024; // 1 Gb
if (is64bit)
Count *= 5; // 5 Gb
char *array = (char *)malloc(Count);
memset(array, 0, Count);
int index = 0;
for (size_t i = 0; i != Count; i++)
array[index++] = char(i) | 1;
if (array[Count - 1] == 0)
printf("The last array element contains 0.n");
free(array);
Этот код корректно работает, если собрать 32-битную версию программы. А вот если собрать 64-битный вариант программы, всё намного интересней.
64-битная программа выделяет массив байт размеров в 5 гигабайт и заполняет его нулями. Затем в цикле массив заполняется какими-то случайными числами, неравными нулю. Чтобы числа не были равны 0, используется "| 1".
Попробуйте угадать, как поведёт себя эта программа, собранная в режиме x64 с помощью компилятора, входящего в состав Visual Studio 2015. Заготовили ответ? Если да, то продолжим.
Если вы запустите отладочную версию этой программы, то она упадёт из-за выхода за границу массива. В какой-то момент переменная index переполнится и её значение станет равно ?2147483648 (INT_MIN).
Логичное объяснение? Ничего подобного! Это неопределённое поведение и произойти может всё что угодно.
Дополнительные ссылки:
- Integer overflow
- Understanding Integer Overflow in C/C++
- Is signed integer overflow still undefined behavior in C++?
Когда я или кто-то ещё говорит, что это неопределённое поведение, люди начинают ворчать. Я не знаю почему, но люди уверены, что точно знают, как работают вычисления в C/C++ и как ведут себя компиляторы.
Но на самом деле они этого не знают. Если бы знали, они бы не говорили всякие глупости. Обычно глупости выглядят как-то так (собирательный образ):
Вы несете теоретический бред. Ну да, формально переполнение 'int' приводит к неопределенному повреждению. Но это не более чем болтовня. На практике, всегда можно сказать что получится. Если к INT_MAX прибавить 1, мы получим INT_MIN. Быть может и есть какие-то экзотические архитектуры, где это не так, но мой компилятор Visual C++ / GCC выдают корректный результат.
Так вот, сейчас я без всякой магии на простом примере продемонстрирую неопределённое поведение и не на какой-то волшебной архитектуре, а в Win64-программе.
Достаточно собрать приведённый выше пример в режиме Release x64 и запустить его. Программа перестанет падать, а сообщение «the last array element contains 0» выдано не будет.
Неопределенное поведение здесь проявило себя следующим образом. Массив будет полностью заполнен, не смотря, на то, что тип 'int' недостаточен для индексации всех элементов массива. Для тех, кто не верит, предлагаю взглянуть на ассемблерный код:
int index = 0;
for (size_t i = 0; i != Count; i++)
000000013F6D102D xor ecx,ecx
000000013F6D102F nop
array[index++] = char(i) | 1;
000000013F6D1030 movzx edx,cl
000000013F6D1033 or dl,1
000000013F6D1036 mov byte ptr [rcx+rbx],dl
000000013F6D1039 inc rcx
000000013F6D103C cmp rcx,rdi
000000013F6D103F jne main+30h (013F6D1030h)
Вот оно проявление неопределенного поведения! И никаких экзотических компиляторов. Это VS2015.
Если заменить 'int' на 'unsigned' неопределённое поведение исчезнет. Массив будет заполнен только частично и в конце будет выдано сообщение «the last array element contains 0».
Ассемблерный код, когда используется 'unsigned':
unsigned index = 0;
000000013F07102D xor r9d,r9d
for (size_t i = 0; i != Count; i++)
000000013F071030 mov ecx,r9d
000000013F071033 nop dword ptr [rax]
000000013F071037 nop word ptr [rax+rax]
array[index++] = char(i) | 1;
000000013F071040 movzx r8d,cl
000000013F071044 mov edx,r9d
000000013F071047 or r8b,1
000000013F07104B inc r9d
000000013F07104E inc rcx
000000013F071051 mov byte ptr [rdx+rbx],r8b
000000013F071055 cmp rcx,rdi
000000013F071058 jne main+40h (013F071040h)
Примечание про PVS-Studio
Анализатор PVS-Studio напрямую не диагностирует переполнение знаковых переменных. Это неблагодарное занятие. Почти невозможно предсказать, какие значения будут иметь те или иные переменные и произойдет переполнение или нет. Однако, он может заметить в этом коде ошибочные паттерны, которые он связывает с «64-битными ошибками».
На самом деле никаких 64-битных ошибок нет. Есть просто ошибки, например, неопределённое поведение. Просто эти ошибки спят в 32-битном коде и проявляют себя в 64-битном. Но если говорить про неопределённое поведение, то это не интересно, и никто покупать анализатор не будет. Да ещё и не поверят, что могут быть какие-то проблемы. А вот если анализатор говорит, что переменная может переполниться в цикле, и что это ошибка «64-битная», то совсем другое дело. Profit.
Приведенный выше код PVS-Studio считает ошибочным и выдаёт предупреждения, относящиеся к группе 64-битных диагностик. Логика следующая: в Win32 переменные типа size_t являются 32-битными, массив на 5 гигабайт выделить нельзя и всё корректно работает. В Win64 стало много памяти, и мы захотели работать с большим массивом. Но код отказал и даёт сбой. Т.е. 32-битный код работает, а 64-битный нет. В рамках PVS-Studio это называется 64-битной ошибкой.
Вот диагностические сообщения, которые выдаст PVS-Studio на код приведённый в начале:
- V127 An overflow of the 32-bit 'index' variable is possible inside a long cycle which utilizes a memsize-type loop counter. consoleapplication1.cpp 16
- V108 Incorrect index type: array[not a memsize-type]. Use memsize type instead. consoleapplication1.cpp 16
Подробнее на тему 64-битных ловушек предлагаю познакомиться со следующими статьями:
- Разработка 64-битных приложений на языке Си/Си++
- 64-битный конь, который умеет считать
- Коллекция примеров 64-битных ошибок в реальных программах
- C++11 и 64-битные ошибки
Корректный код
Чтобы всё работало хорошо, надо использовать подходящие типы данных. Если вы собираетесь обрабатывать большие массивы, то забудьте про int и unsigned. Для этого есть типы ptrdiff_t, intptr_t, size_t, DWORD_PTR, std::vector::size_type и так далее. В данном случае пусть будет size_t:
size_t index = 0;
for (size_t i = 0; i != Count; i++)
array[index++] = char(i) | 1;
Вывод
Если конструкция языка С++ вызывает неопределённое поведение, то она его вызывает и не надо с этим спорить или предсказывать как оно проявит себя. Просто не пишите опасный код.
Есть масса упрямых программистов, которая не хочет видеть ничего опасного в сдвигах отрицательных чисел, переполнении знаковых чисел, сравнивании this c нулём и так далее.
Не будьте в их числе. То, что программа сейчас работает, ещё ничего не значит. Как проявит UB предсказать невозможно. Ожидаемое поведение программы — это всего лишь один из вариантов UB.
Автор: PVS-Studio