Урок 17. Паттерн 9. Смешанная арифметика

24.01.2012

Надеемся, вы уже успели отдохнуть от 13 урока и теперь сможете рассмотреть еще один важный паттерн ошибок, связанный с арифметическими выражениями, в которых участвуют типы различной размерности.

Смешанное использование memsize- и не memsize-типов в выражениях может приводить к некорректным результатам на 64-битных системах и быть связано с изменением диапазона входных значений. Рассмотрим ряд примеров:

size_t Count = BigValue;
for (unsigned Index = 0; Index != Count; ++Index)
{ ... }  

Это пример вечного цикла, если Count > UINT_MAX. Предположим, что на 32-битных системах этот код работал с количеством итераций менее значения UINT_MAX. Но 64-битный вариант программы может обрабатывать больше данных, и ему может потребоваться большее количество итераций. Поскольку значения переменной Index лежат в диапазоне [0..UINT_MAX], то условие "Index != Count" никогда не выполнится, что и приводит к бесконечному циклу.

Примечание. Заметим, что при определенных настройках компилятора данный пример может успешно отработать. Из-за этого иногда, кажется, что код корректен, и возникает путаница. В одном из последующих уроков мы расскажем о фантомных ошибках, проявляющих себя только временами. Если вам интересно уже сейчас понять это странное поведение кода, то предлагаем ознакомиться со статьей "64-битный конь, который умеет считать".

Для исправления кода необходимо использовать в выражениях только memsize-типы. В данном примере можно заменить тип переменной Index с unsigned на size_t.

Другая часто встречающаяся ошибка - запись выражений следующего вида:

int x, y, z;
ptrdiff_t SizeValue = x * y * z;

Ранее уже рассматривались подобные примеры, когда при вычислении значений с использованием не memsize-типов происходило арифметическое переполнение. И конечный результат был некорректен. Поиск и исправление приведенного кода осложняется тем, что компиляторы, как правило, не выдают на него никаких предупреждений. С точки зрения языка Си++ это совершенно корректная конструкция. Происходит умножение нескольких переменных типа int, после чего результат неявно расширяется до типа ptrdiff_t и происходит присваивание.

Приведем небольшой код, показывающий опасность неаккуратных выражений со смешанными типами (результаты получены с использованием Microsoft Visual C++ 2005, 64-битный режим компиляции):

int x = 100000;
int y = 100000;
int z = 100000;
ptrdiff_t size = 1;                   // Result:
ptrdiff_t v1 = x * y * z;             // -1530494976
ptrdiff_t v2 = ptrdiff_t (x) * y * z; // 1000000000000000
ptrdiff_t v3 = x * y * ptrdiff_t (z); // 141006540800000
ptrdiff_t v4 = size * x * y * z;      // 1000000000000000
ptrdiff_t v5 = x * y * z * size;      // -1530494976
ptrdiff_t v6 = size * (x * y * z);    // -1530494976
ptrdiff_t v7 = size * (x * y) * z;    // 141006540800000
ptrdiff_t v8 = ((size * x) * y) * z;  // 1000000000000000
ptrdiff_t v9 = size * (x * (y * z));  // -1530494976

Необходимо чтобы все операнды в подобных выражениях были приведены в процессе вычисления к типу большей разрядности. Помните, что выражение вида

ptrdiff_t v2 = ptrdiff_t (x) + y * z;

вовсе не гарантирует правильный результат. Оно гарантирует только то, что выражение " ptrdiff_t (x) + y * z" будет иметь тип ptrdiff_t.

Следовательно, если результатом выражения должен являться memsize-тип, то в выражении должны участвовать только memsize-типы. Или элементы, приведенные к memsize-типам. Правильный вариант:

ptrdiff_t v2 = ptrdiff_t (x) + ptrdiff_t (y) * ptrdiff_t (z); // OK!

Впрочем не всегда необходимо приводить все аргументы к memsize-типу. Если выражение состоит из одинаковых операторов, то достаточно привести к memsize-типу только первый аргумент. Рассмотрим пример:

int c();
int d();
int a, b;
ptrdiff_t v2 = ptrdiff_t (a) * b * c() * d();

Порядок вычисления выражения с операторами одинакового приоритета не определен. Точнее, компилятор волен вычислять подвыражения, например вызов функций c() и d() в том порядке, который он считает более эффективным, даже если подвыражения вызывают побочные эффекты. Порядок возникновения побочных эффектов не определен. Но поскольку операция умножения относится к лево-ассоциативным операторам, то вычисление будет происходить следующим образом:

ptrdiff_t v2 = ((ptrdiff_t (a) * b) * c()) * d();

В результате каждый из операндов перед умножением будет преобразовываться к типу ptrdiff_t и мы получим корректный результат.

Примечание. Если у вас есть целочисленные вычисления, для которых крайне важен контроль над переполнениями, то мы предлагаем обратить внимание на класс SafeInt, реализацию и описание которого можно найти в MSDN.

Смешанное использование типов может проявляться и в изменении программной логики:

ptrdiff_t val_1 = -1;
unsigned int val_2 = 1;
if (val_1 > val_2)
  printf ("val_1 is greater than val_2\n");
else
  printf ("val_1 is not greater than val_2\n");
//Output on 32-bit system: "val_1 is greater than val_2"
//Output on 64-bit system: "val_1 is not greater than val_2"

На 32-битной системе переменная val_1 согласно правилам языка Си++ расширялась до типа unsigned int и становилась значением 0xFFFFFFFFu. В результате условие "0xFFFFFFFFu > 1" выполнялось. На 64-битной системе наоборот расширяется переменная val_2 до типа ptrdiff_t. В этом случае уже проверяется выражение "-1 > 1". На рисунке 1 и 2 схематично отображены происходящие преобразования.

Рисунок 1 - Преобразования, происходящие в 32-битном коде

Рисунок 1 - Преобразования, происходящие в 32-битном коде

Рисунок 2 - Преобразования, происходящие в 64-битном коде

Рисунок 2 - Преобразования, происходящие в 64-битном коде

Если вам необходимо вернуть прежнее поведение кода - следует изменить тип переменной val_2:

ptrdiff_t val_1 = -1;
size_t val_2 = 1;
if (val_1 > val_2)
  printf ("val_1 is greater than val_2\n");
else
  printf ("val_1 is not greater than val_2\n");

Правильнее вообще не сравнивать знаковые и беззнаковые типы, но это выходит за рамки обсуждаемой темы.

Мы рассмотрели только простые выражения. Но описываемые проблемы могут проявиться и при использовании других конструкций языка Си++:

extern int Width, Height, Depth;
size_t GetIndex(int x, int y, int z) {
  return x + y * Width + z * Width * Height;
}
...
MyArray[GetIndex(x, y, z)] = 0.0f;

В случае работы с большими массивами (более INT_MAX элементов) данный код будет вести себя некорректно, и мы будем адресоваться не к тем элементам массива MyArray, к которым рассчитываем. Несмотря на то, что мы возвращаем значение типа size_t, выражение "x + y * Width + z * Width * Height" вычисляется с использованием типа int. Мы думаем, вы уже догадались, что исправленный код будет выглядеть следующим образом:

extern int Width, Height, Depth;
size_t GetIndex(int x, int y, int z) {
  return (size_t)(x) +
         (size_t)(y) * (size_t)(Width) +
         (size_t)(z) * (size_t)(Width) * (size_t)(Height);
}

Или чуть более просто:

extern int Width, Height, Depth;
size_t GetIndex(int x, int y, int z) {
  return (size_t)(x) +
         (size_t)(y) * Width +
         (size_t)(z) * Widt) * Height;
}

В следующем примере, у нас вновь смешивается memsize-тип (указатель) и 32-битный тип unsigned:

extern char *begin, *end;
unsigned GetSize() {
  return end - begin;
}

Результат выражения "end - begin" имеет тип ptrdiff_t. Поскольку функция возвращает тип unsigned, то происходит неявное приведение типа, при котором старшие биты результата теряются. Таким образом, если указатели begin и end ссылаются на начало и конец массива, по размеру большего UINT_MAX (4Gb), то функция вернет некорректное значение.

И еще один пример. На этот раз рассмотрим не возвращаемое значение, а формальный аргумент функции:

void foo(ptrdiff_t delta);
int i = -2;
unsigned k = 1;
foo(i + k);

Этот код не напоминает вам пример с некорректной арифметикой указателей, рассмотренный в 13-том уроке? Да, здесь происходит то же самое. Некорректный результат возникает при неявном расширении фактического аргумента, имеющего значение 0xFFFFFFFF и тип unsigned, до типа ptrdiff_t.

Диагностика

Ошибки, возникающие на 64-битных системах при смешанном использование простых целочисленных типов и memsize-типов, представлены большим количеством синтаксических конструкций языка Си++. Для диагностики этих ошибок используется целый ряд диагностических сообщений. Анализатор PVS-Studio предупреждает о потенциально возможных ошибках, используя следующие сообщения: V101, V103, V104, V105, V106, V107, V109, V110, V121.

Вернемся к ранее рассмотренному примеру:

int c();
int d();
int a, b;
ptrdiff_t x = ptrdiff_t(a) * b * c() * d();

Хотя само выражение перемножает аргументы, расширяя их тип до ptrdiff_t, ошибка может содержаться в вычислении самих этих аргументов. Поэтому анализатор все равно предупреждает о смешивании типов: "V104: Implicit type conversion to memsize type in an arithmetic expression".

Также инструмент PVS-Studio позволяет найти потенциально опасные выражения, которые скрываются за явным приведением типов. Для этого можно включить в настройках анализатора предупреждения V201 и V202. По умолчанию анализатор не выдает предупреждения связанные с приведением типа, в случае, когда приведение осуществляется явно. Пример:

TCHAR *begin, *end;
unsigned size = static_cast<unsigned>(end - begin);

Выявить подобный некорректный код и позволяют сообщения V201 и V202.

При этом анализатор не обратит внимания на безопасные с точки зрения 64-битного кода приведения типов:

const int *constPtr;
int *ptr = const_cast<int>(constPtr);
float f = float(constPtr[0]);
char ch = static_cast<char>(sizeof(double));

Авторы курса: Андрей Карпов (karpov@viva64.com), Евгений Рыжков (evg@viva64.com).

Правообладателем курса "Уроки разработки 64-битных приложений на языке Си/Си++" является ООО "Системы программной верификации". Компания занимается разработкой программного обеспечения в области анализа исходного кода программ. Сайт компании: http://www.viva64.com.

Контактная информация: e-mail: support@viva64.com, 300027, г. Тула, а/я 1800.