Неопределённое поведение

Кувшинов Д.Р.

2015


Общее оглавление


В языке C++ важную роль играют понятия неопределенное поведение undefined behavior, UB и определяемое реализацией поведение implementation-defined behavior, IB, характеризующие действия, результаты которых не определяются стандартом языка.

Когда некоторое действие объявляется как порождающее UB, это означает, что программист не должен полагаться на какой-то определенный результат — всё зависит от выбора компилятора в данном конкретном случае и особенностей платформы, причём разработчики платформы и компилятора не обязаны указывать в документации последствия такого действия. В случае IB, разработчик компилятора должен выбрать некоторую, разумную с его точки зрения, реализацию и описать это в документации.

Например, компилятор g++ известен тем, что при определённых условиях просто выбрасывает из программы участки, зависящие от неопределённого поведения.

К сожалению, программа, опирающаяся на конкретное поведение на данной платформе с данным компилятором, строго говоря, не является переносимой. Часто использование конструкций, эффект которых заявлен как IB или UB, является неосознанным из-за невнимательности, недостатка опыта или знаний программиста. Если, например, поведение программы различается в отладочной (debug, оптимизация машинного кода компилятором выключена) и окончательной (release, оптимизация включена) сборках, то, скорее всего, виноват код, порождающий UB.

Ярким примером UB и ошибочного кода является повторное использование (в том числе повторное изменение) изменяемой переменной при вычислении выражения, когда относительный порядок вычисления термов не определен (то же касается фактических параметров функции в точке вызова):

++a -= b;                // UB
cout << a << a++ << ++a; // UB
arr[a = 10] = a;         // UB
arr[0] = 0;
arr[arr[0]] = 1;         // UB

Впрочем, в ряде случаев допустимо использовать код, объявленный в стандарте как вызывающий UB или IB, так как определенные гарантии предоставляются основными платформами. Например, вызывающим UB объявлено любое арифметическое действие с целыми числами, результат которого не может быть представлен в используемом машинном представлении числа. Однако подавляющее число процессоров общего назначения реализуют арифметику с оборачиванием (то есть отбрасыванием старших разрядов, не поместившихся в представлении), а отрицательные числа представляют в дополнительном коде, что делает поведение предсказуемым в большинстве случаев. Но даже здесь имеются нюансы. Например, целочисленное деление (включая взятие остатка) на ноль и умножение или деление (включая взятие остатка) наименьшего представимого отрицательного целого числа на –1 по-разному обрабатываются на разных платформах (обе эти ситуации — примеры UB).

В случае операций сдвига поведение не определено, когда правый операнд меньше нуля или не меньше числа бит в представлении либо левый операнд является signed-типом и результат сдвига влево не может быть представлен как произведение левого операнда на степень двойки. Смысл сдвига вправо отрицательных значений определяется реализацией (как правило, это арифметический сдвиг). Поэтому предпочтительно выполнять поразрядные операции только со значениями беззнаковых типов.


Другие примеры UB

enum Command { Halt, Move, Left, Right };
Command translate_command(char command)
{
  switch (command)
  {
    case 'h': return Halt;
    case 'm': return Move;
    case 'l': return Left;
    case 'r': return Right;
  }
}

Если символ command не совпадает ни с одним из указанных четырёх вариантов, мы имеем неопределённое поведение. Всегда следует добавлять секцию default. В случае, если исходя из логики программы секция default никогда не должна быть выполнена, в неё можно поставить всегда срабатывающий assert, для чего есть соответствующий приём (вывод текста assert, описывающий ошибку):

enum Command { Halt, Move, Left, Right };
Command translate_command(char command)
{
  switch (command)
  {
    case 'h': return Halt;
    case 'm': return Move;
    case 'l': return Left;
    case 'r': return Right;
    default:
      assert(!"translate_command: impossible command");
      return Halt;
  }
}
// Строковое представление двоичной записи целого числа.
char* bin_str(unsigned n)
{
  char str[65] {};
  char *p = &str[64];
  for (; n != 0; n >>= 1)
    *--p = '0' + (n & 1);
  return p;
}

Пример bin_str представляет классическую проблему C (и в меньшей мере, C++): управление памятью, занимаемой возвращаемым объектом (в данном случае, строкой). Возвращённый функцией указатель будет указывать на фрагмент уже удалённого массива (так как массив был в автоматической памяти), что является серьёзной ошибкой и порождает неопределённое поведение. Самый простой способ “подправить” этот код, состоит в том, чтобы добавить ключевое слово static, переведя массив из автоматической памяти в статическую (удаляемую только при завершении работы всей программы):

// Строковое представление двоичной записи целого числа.
char* bin_str(uint32_t n)
{
  static char str[33] {};
  char *p = &str[32];
  for (; n != 0; n >>= 1)
    *--p = '0' + (n & 1);
  return p;
}

К сожалению, данный подход также не лишён недостатков. В C++ рекомендуется возвращать объект std::string:

// Строковое представление двоичной записи целого числа.
string bin_str(uint32_t n)
{
  char str[33] {};  // теперь static следует убрать!
  char *p = &str[32];
  for (; n != 0; n >>= 1)
    *--p = '0' + (n & 1);
  return p; // автоматически создаст объект string
}

Похожие ошибки иногда допускают даже весьма опытные программисты. Следующий пример был найден в компиляторе gcc:

static void
dump_hsa_symbol (FILE *f, hsa_symbol *symbol)
{
  const char *name;
  if (symbol->m_name)
    name = symbol->m_name;
  else
  {
    char buf[64];  // <--- локальная переменная блока
    sprintf (buf, "__%s_%i", hsa_seg_name (symbol->m_segment),
       symbol->m_name_number);
     name = buf;   // <--- указатель, внешний к данному блоку
  }
  fprintf (f, "align(%u) %s_%s %s",
           hsa_byte_alignment (symbol->m_align),
           hsa_seg_name(symbol->m_segment),
           hsa_type_name(symbol->m_type & ~BRIG_TYPE_ARRAY_MASK),
           name); // <--- используется вне блока, UB
  ....
}
inline float abs(float x)
{
  // Ошибка компиляции, если размеры типов не равны:
  static_assert(sizeof(float) == sizeof(uint32_t));
  static const uint32_t mask = (uint32_t(1) << 31) - 1;
  *(uint32_t*)&f &= mask;
  return f;
}
inline float abs(float x)
{
  // ошибка компиляции, если размеры типов не равны:
  static_assert(sizeof(float) == sizeof(uint32_t));
  static const uint32_t mask = (uint32_t(1) << 31) - 1;
  union { float f; uint32_t i } convert;
  convert.f = x;
  convert.i &= mask;
  return convert.f;
}

Примеры, подобные двум вариантам abs, встречаются довольно часто в реальном коде: как правило, компиляторы делают то, что задумано (особенно вариант с union, многие программисты считают подобный код корректным), однако Стандартом это не гарантируется, а значит, такой код может “вдруг” перестать корректно работать на другой версии компилятора.


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

struct S { int field; };

void do_something(S* p)
{
  // Несмотря на то, что мы всего лишь берём адрес поля,
  // а не обращаемся к его значению -- формально мы выполняем разыменование,
  // т.е. в случае p == nullptr имеем UB.
  int *field_ptr = &p->some_field;
  // ...
  // Т.к. выше в случае p == nullptr имеем UB, компилятор может
  // оптимизировать код с предположением, что p != nullptr (для случая без UB!)
  // и убрать эту проверку как лишнюю.
  if (p != nullptr)
  {
     // Код в этом блоке может быть выполнен даже если p == nullptr.
     // ...
  }
}


Во многих сомнительных случаях (не только UB) современные компиляторы способны генерировать предупреждения о возможной ошибке, поэтому желательно включить их и внимательно читать отчёты компилятора.

Ещё на тему UB: Undefined behavior can result in time travel.


Общее оглавление

Кувшинов Д.Р. © 2015