>
>
>
Путеводитель C++ программиста по неопре…

Андрей Карпов
Статей: 671

Дмитрий Свиридкин
Статей: 9

Путеводитель C++ программиста по неопределённому поведению: часть 2 из 11

Вашему вниманию предлагается вторая часть электронной книги, которая посвящена неопределённому поведению. Книга не является учебным пособием и рассчитана на тех, кто уже хорошо знаком с программированием на C++. Это своего рода путеводитель C++ программиста по неопределённому поведению, причём по самым его тайным и экзотическим местам. Автор книги — Дмитрий Свиридкин, редактор — Андрей Карпов.

Целые и вещественные числа: переполнение целых знаковых чисел

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

Компьютер не может напрямую работать с бесконечно "длинными" числами — хранить все их цифры. Как бы много оперативной памяти у нас ни было, всё же она конечна. Да и хранить, и обрабатывать величины, сопоставимые с числом атомов в видимой части Вселенной — безнадёжное занятие.

Тем не менее при выполнении операций над целыми числами мы всё же имеем шанс выпасть за пределы допустимого диапазона (например, [-2^31, 2^31-1] для int32). И тут в игру вступают особенности поддержки целых чисел для того или иного языка программирования, а также, быть может, особенности реализации конкретной платформы.

При выполнении инструкции add (iadd) платформы х86 переполнение целого числа сопровождается выставлением специального флага переполнения, а результирующее значение просто получается отбрасыванием старшего бита результата. И следует ожидать, что по окончании работы условной программы:

x = 2^31 - 1
iadd x 5

произойдёт перенос разряда в знаковый бит, и переменная x примет отрицательное значение.

В реализации конкретного языка программирования может быть проверка флага переполнения и сообщение об ошибке. А может и не быть. Может быть гарантия "цикличности" значений (после 2^31-1 идёт -2^31), а может и не быть.

Проверки и гарантии — это дополнительные инструкции, которые нужно генерировать компилятору, а процессору потом исполнять.

В языке C++ решили не жертвовать производительностью и заставлять компиляторы генерировать код проверки, а объявили переполнение целых знаковых (signed) чисел неопределённым, открывая простор для оптимизаций. Компилятор может генерировать любой код, какой ему вздумается, ориентируясь лишь на одно правило: переполнения не бывает.

Многие программисты свято верят, что переполнение чисел работает, как ожидается, "циклично", и пишут проверки вида:

if (x > 0 && a > 0 && x + a <= 0) {
    // обработай переполнение
}

Но, увы, это неопределённое поведение. И компилятор имеет полное право выкинуть такую проверку.

int main() {
    int x = 2'000'000'000;
    int y = 0;
    std::cin >> y;

    if (x > 0 && y > 0 && x + y <=0){
        return 5;
    }
    return 0;
}

Обратите внимание, что в ассемблерном коде после вызова функции чтения из потока (call) сразу следует обнуление регистра eax (xor eax, eax) и возвращение его как результата функции.

main:
 sub     rsp, 24
 mov     edi, OFFSET FLAT:std::cin
 lea     rsi, [rsp+12]
 mov     DWORD PTR [rsp+12], 0
 call    std::basic_istream<char, std::char_traits<char> >::operator>>(int&)
 xor     eax, eax
 add     rsp, 24
 ret

Искусственный пример может быть недостаточно убедительным, так что обратим внимание на следующую — вполне серьёзную — функцию вычисления полиномиального хэша строки:

int hash_code(std::string s) {
    int h = 13;
    for (char c : s) {
        h += h * 27752 + c;
    }
    if (h < 0) h += std::numeric_limits<int>::max();
    return h;
}

Функция, которая по задумке никогда не должна возвращать отрицательные числа, таки выдаёт отрицательное число! Из-за неопределённого поведения и бессмысленной с точки зрения компилятора проверки.

Компилятор может руководствоваться следующей логикой:

  • Если значение h положительно, то независимо от символа c величина h*27752 + с будет положительной: величина c мала, а переполнения не бывает.
  • На первой итерации h положительно, мы суммируем положительные числа, переполнений в корректной программе не бывает, значит на каждой итерации значение будет оставаться положительным.
  • Конечная сумма в итоге должна получиться положительной, и проверка не нужна.

Другой замечательный, но искусственный пример, для большего устрашения: конечный цикл может стать бесконечным! Пример взят из публикации "Shocking Examples of Undefined Behaviour":

int main() {
  char buf[50] = "y";
  for (int j = 0; j < 9; ++j) {
    std::cout << (j * 0x20000001) << std::endl;
    if (buf[0] == 'x') break;
  }
}

Компилятор выполняет удивительную оптимизацию умножения константы на последовательные числа, полностью изменяя заголовок цикла и условия остановки:

for(int j = 0; j < 9*0x20000001; j += 0x20000001) {
  ....
}

Условие j < 9*0x20000001 всегда истинно, так как правая часть больше, чем std::numeric_limits<int>::max().

С современными версиями компиляторов этот пример особенно занятен. GCC в подобных циклах иногда способны заметить переполнение и выдать предупреждение. Но этого не произошло... Однако если мы закомментируем недостижимый break и buf, мы получим предупреждение:

<source>:6:37: warning:
iteration 4 invokes undefined behavior [-Waggressive-loop-optimizations]
    6 |         std::cout << (j * 0x20000001) << std::endl;
      |                                     ^
<source>:5:23: note: within this loop
    5 |     for (int j = 0; j < 9; ++j) {

Если раскомментировать объявление buf, то предупреждение пропадёт (GCC 13.2).

Бывает и наоборот. Ждёшь последствия от переполнения, а его нет, и код магическим образом работает. Пример из статьи "Undefined behavior ближе, чем вы думаете":

size_t Count = size_t(5) * 1024 * 1024 * 1024; // 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;

Инкрементируясь, 32-битная знаковая переменная index в какой-то момент переполнится и, кажется, должна стать отрицательной. После чего произойдёт Access Violation при выходе за границу массива. Но в случае UB никто никому ничего не должен.

Компилятор решает в целях оптимизации использовать для переменной index 64-битный регистр, который отлично увеличивается, и все элементы массива успешно заполняются. И он в своём праве: если переполнение не должно возникать, то и использовать 32-битный регистр для индекса он не обязан.

Другой, возможно, более известный и иногда полезный пример оптимизации, которую такое неопределённое поведение упрощает для компилятора — сворачивать известные суммы.

Например, при суммировании арифметических прогрессий и некоторых других известных рядов Clang 12 генерирует совершенно разный код для знаковых и беззнаковых чисел.

Вариант со знаковыми типами:

// суммируем квадраты от 1 до N
int64_t summate_squares(int64_t n) {
    int64_t sum = 0;
    for (int64_t i = 1; i <= n; ++i) {
        sum += i * i;
    };
    return sum;
}

Ассемблерный листинг (x86-64 clang 12.0.1, -std=c++20 -O3). Обратите внимание, что здесь нет цикла. Используется известная формула (N * (N + 1)) * (2N + 1) / 6, но довольно сложным способом:

summate_squares(long):                   # @summate_squares(long)
        test    rdi, rdi
        jle     .LBB2_1
        lea     rax, [rdi - 1]
        lea     rcx, [rdi - 2]
        mul     rcx
        mov     r8, rax
        mov     rsi, rdx
        lea     rcx, [rdi - 3]
        mul     rcx
        imul    ecx, esi
        add     edx, ecx
        shld    rdx, rax, 63
        movabs  rax, 6148914691236517206
        shld    rsi, r8, 63
        imul    rax, rdx
        lea     rcx, [rsi + 4*rsi]
        add     rcx, rax
        lea     rax, [rcx + 4*rdi]
        add     rax, -3
        ret
.LBB2_1:
        xor     eax, eax
        ret
*/

Вариант с беззнаковыми типами:

uint64_t usummate_squares(uint64_t n) {
    uint64_t sum = 0;
    for (uint64_t i = 1; i <= n; ++i) {
        sum += i * i;
    };
    return sum;
}

Здесь цикл есть. Переполнение беззнаковых типов определено и требует обработки:

usummate_squares(unsigned long):       # @usummate_squares(unsigned long)
        test    rdi, rdi
        je      .LBB3_1
        mov     ecx, 1
        xor     eax, eax
.LBB3_4:                               # =>This Inner Loop Header: Depth=1
        mov     rdx, rcx
        imul    rdx, rcx
        add     rax, rdx
        add     rcx, 1
        cmp     rcx, rdi
        jbe     .LBB3_4
        ret
.LBB3_1:
        xor     eax, eax
        ret

GCC 13 на момент написания текста (2024 год) в принципе не делает таких оптимизаций по умолчанию. При этом последние версии Clang 18 уже способны свернуть цикл суммирования квадратов и для беззнаковых:

usummate_squares(unsigned long):       # @usummate_squares(unsigned long)
        test    rdi, rdi
        je      .LBB3_1
        inc     rdi
        cmp     rdi, 3
        mov     r8d, 2
        cmovae  r8, rdi
        lea     rax, [r8 - 2]
        lea     rcx, [r8 - 3]
        mul     rcx
        mov     rsi, rax
        mov     rcx, rdx
        lea     rdi, [r8 - 4]
        mul     rdi
        imul    edi, ecx
        add     edx, edi
        shld    rdx, rax, 63
        movabs  rax, 6148914691236517206
        shld    rcx, rsi, 63
        imul    rax, rdx
        lea     rcx, [rcx + 4*rcx]
        add     rcx, rax
        lea     rax, [rcx + 4*r8]
        add     rax, -7
        ret
.LBB3_1:
        xor     eax, eax
        ret

Читатели, искушённые в теории колец вычетов, могут для беззнаковой версии написать более простой и короткий ассемблерный код в качестве упражнения (нужно лишь правильно поделить на 6).

Корректные проверки переполнения в арифметических операциях намного сложнее, чем просто смена знака.

Так, для C++20 безопасный обобщённый код арифметических операций над целыми знаковыми числами мог бы выглядеть следующим образом:

#include <concepts>
#include <type_traits>
#include <variant>
#include <limits>

namespace safe {

// Все эти проверки справедливы только для целых знаковых чисел
template <class T>
concept SignedInteger = std::is_signed_v<T>
                     && std::is_integral_v<T>;

enum class ArithmeticError {
    Overflow,
    ZeroDivision
};

template <SignedInteger I>
using ErrorOrInteger = std::variant<I, ArithmeticError>;

template <SignedInteger I>
ErrorOrInteger<I> add(I a,    // выключаем вывод параметра шаблона по
                      std::type_identity_t<I> b) // второму аргументу
{
    if (b > 0 && a > std::numeric_limits<I>::max() - b) {
        // положительное переполнение
        return ArithmeticError::Overflow;
    }
    if (b < 0 && a < std::numeric_limits<I>::min() - b) {
        // отрицательное переполнение
        return ArithmeticError::Overflow;
    }
    return a + b;
}

template <SignedInteger I>
ErrorOrInteger<I> sub(I a, std::type_identity_t<I> b) {
    if (b < 0 && a > std::numeric_limits<I>::max() + b) {
        // положительное переполнение
        return ArithmeticError::Overflow;
    }
    if (b > 0 && a < std::numeric_limits<I>::min() + b) {
        // отрицательное переполнение
        return ArithmeticError::Overflow;
    }
    return a - b;
}

template <SignedInteger I>
ErrorOrInteger<I> mul(I a, std::type_identity_t<I> b) {
   if (a == 0 || b == 0) {
       return 0;
   }

   if (a > 0) {
       if (b > 0) {
           if (a > std::numeric_limits<I>::max() / b) {
              return ArithmeticError::Overflow;
           }
       } else {
           if (b < std::numeric_limits<I>::min() / a) {
              return ArithmeticError::Overflow;
            }
      }
   } else {
      if (b > 0) {
          if (a < std::numeric_limits<I>::min() / b) {
              return ArithmeticError::Overflow;
          }
      } else {
          if (b < std::numeric_limits<I>::max() / a) {
              return ArithmeticError::Overflow;
          }
      }
   }
   return a * b;
}

template <SignedInteger I>
ErrorOrInteger<I> div(I a, std::type_identity_t<I> b) {
  if (b == 0) {
      return ArithmeticError::ZeroDivision;
  }

  if (a == std::numeric_limits<I>::min() && b == -1) {
      // диапазон [min, max] несимметричный относительно 0.
      // abs(min) > max — будет переполнение
      return ArithmeticError::Overflow;
  }
  return a / b;
}


template <SignedInteger I>
ErrorOrInteger<I> mod(I a, std::type_identity_t<I> b) {
  if (b == 0) {
      return ArithmeticError::ZeroDivision;
  }

  if (b == -1) {
      // По стандарту в этом случае также неопределенное поведение при
      // a == std::numeric_limits<I>::min()
      // поскольку остаток и неполное частное от деления,
      // например, на платформе x86
      // получаются одной и той же инструкцией div (idiv),
      // что потребует дополнительной обработки.
      //
      // Но совершенно ясно, что остаток от деления чего угодно на -1 равен 0
      return 0;
  }
  return a % b;
}

}

Если вам не нравится возвращать ошибку или результат, можете использовать исключения.

Видно, что безопасные версии арифметических операций должны быть как минимум в два раза медленнее своих исходно небезопасных версий. Такая экономия тактов может быть оправдана, если вы разрабатываете, например, математическую библиотеку, и вся ваша производительность упирается в CPU и перемалывание чисел.

Однако если ваша программа только и делает, что ожидает и выполняет IO операции, то траты в два раза большего числа тактов на сложение или умножение никто и не заметит. Да и язык C++ для таких программ чаще всего не лучший выбор.

Итак, если вы работаете только лишь с беззнаковыми числами (unsigned), то с неопределённым поведением при переполнении никаких проблем нет: всё определено как вычисления по модулю 2^N (N — количество бит для выбранного типа чисел).

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

Для выведения ограничений вам помогут отладочные assert с правильными проверками переполнения, которые нужно написать. Или включение ubsan (undefined behavior sanitizer) при сборке компиляторами Clang или GCC. А также тестовые constexpr вычисления.

Также проблемы неопределённого поведения при переполнении касаются битовых сдвигов влево для отрицательных чисел (или при сдвиге положительного числа с залезанием в знаковый бит). Начиная с C++20, стандарт требует фиксированной единой реализации отрицательных чисел — через дополнительный код (two's complement), и многие проблемы сдвигов сняты. Тем не менее всё равно стоит следовать общей рекомендации: любые битовые операции выполнять только в unsigned типах.

Дополнительный код — наиболее распространённый способ представления отрицательных целых чисел в компьютерах. Он позволяет заменить операцию вычитания на операцию сложения и сделать операции сложения и вычитания одинаковыми для знаковых и беззнаковых чисел.

Дополнительный код для отрицательного числа можно получить инвертированием его двоичного модуля (получается "первое дополнение") и прибавлением к инверсии единицы (получается "второе дополнение").

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

Стоит заметить, что сужающее преобразование из целочисленного типа в другой целочисленный тип к неопределённому поведению не приводит, и выполнять побитовое и с маской перед присваиванием переменной меньшего типа необязательно. Но желательно, чтобы избежать предупреждений компилятора:

constexpr int x = 12345678;
constexpr uint8_t first_byte = x; // Implicit cast. Warning

Очень неприятным является переполнение целочисленных переменных, возникающее из-за правил integer promotion:

constexpr std::uint16_t IntegerPromotionUB(std::uint16_t x) {
    x *= x;
    return x;
}

// 65535 * 65535 mod 1<<16 = 1

static_assert(IntegerPromotionUB(65535) == 1); // won't compile

Несмотря на то, что для беззнаковых типов переполнение определено как взятие остатка по модулю 2^n, и мы используем только беззнаковую переменную, из-за integer promotion в этом примере возникает переполнение знакового (!) числа и вытекающее из этого UB.

Справедливости ради надо заметить, что такое происходит только на платформах, где размер int больше uint16_t (то есть практически везде в наши дни).

x *= x; // переписывается как x = x * x;

Тип uint16 меньше, чем тип int. Для умножения выполняется неявное приведение к int.

Полезные ссылки

Целые и вещественные числа: числа с плавающей точкой

С float и double в принципе всегда всё сложно. Особенно в C++.

Стандарт C++ не требует следования стандарту IEEE 754, потому деление на ноль в вещественных числах также считается неопределённым поведением несмотря на то, что по IEEE 754 выражение x/0.0 определяется как -INFNaN, или INF в зависимости от знака числа x (NaN для нуля).

Сравнение вещественных чисел — излюбленная головная боль.

Выражение x == y фактически является кривым побитовым сравнением для чисел с плавающей точкой, по-особенному работающее со случаями -0.0 и +0.0, и NaN. О существовании этого и != операторов для вещественных чисел стоит забыть и никогда не вспоминать.

На тот случай, если вам по наследству достался большой проект, и хочется узнать, как в нём обстоит дело со сравнением чисел с плавающей точкой, вы можете воспользоваться анализатором PVS-Studio. В нём есть диагностика V550: Suspicious precise comparison.

Для побитового сравнения нужно использовать memcmp. Для сравнения чисел — приближенные варианты вида std::abs(x - y) < EPS, где EPS — какое-то абсолютное или вычисляемое на основе x и y значение. А также различные манипуляции с ULP сравниваемых чисел.

Так как стандарт C++ не форсирует IEEE 754, проверки на x == NaN через его свойство (x != x) == true могут быть убраны компилятором как заведомо ложные. Проверять нужно с помощью предназначенных для этого функций std::isnan.

Поддерживается или нет IEEE 754, можно проверить с помощью предопределённой константы std::numeric_limits<FloatType>::is_iec559

Сужающие преобразования из float в знаковые или беззнаковые целые могут повлечь неопределённое поведение, если значение непредставимо в целочисленном типе. Никаких обрезок по модулю 2^N не предполагается.

constexpr uint16_t x = 1234567.0; // CE, undefined behavior

Обратное преобразование (из целочисленных типов во float/double) также имеет свои подвохи, не связанные с неопределённым поведением: большие по абсолютной величине целые числа теряют точность.

static_assert(
  static_cast<float>(std::numeric_limits<int>::max()) ==  // OK
  static_cast<float>(static_cast<long long>(
     std::numeric_limits<int>::max()) + 1) 
);

static_assert(
 static_cast<double>((1LL << 53) - 1) == static_cast<double>(1LL << 53) // Fire!
);

static_assert(
 static_cast<double>((1LL << 54) - 1) == static_cast<double>(1LL << 54) // OK
);

static_assert(
 static_cast<double>((1LL << 55) - 1) == static_cast<double>(1LL << 55) // OK
);

static_assert(
 static_cast<double>((1LL << 56) - 1) == static_cast<double>(1LL << 56) // OK
);

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

Плавающая точка и шаблоны

До C++20 вещественные числа нельзя было использовать в качестве параметров-значений в шаблонах. Теперь же можно. Правда, ожидать, что вы насчитаете в run-time и в compile-time одно и то же, не стоит.

Код, правда, на C, но суть та же. Первый вызов функции expl (возведение числа E в степень X) разворачивается в константу, а второй по-честному вычисляется:

#include <stdio.h>
#include <string.h>
#include <math.h>

static void printBits(size_t const size, void const * const ptr)
{
    unsigned char *b = (unsigned char*) ptr;
    unsigned char byte;
    int i, j;

    for (i = size * 8; i > 0; i--) {
        if( i % 8 == 0)
        {
            printf("%d", i);
            if( i >= 100) i-=2;
            else if( i >= 10) i-=1;
        }
        else {printf(" ");}
    }
    printf("\n");
    for (i = size * 8; i > 0; i--) {
        if( i%8 == 0) {printf("|");} else {printf(" ");}
    }
    printf("\n");
    for (i = size-1; i >= 0; i--) {
        for (j = 7; j >= 0; j--) {
            byte = (b[i] >> j) & 1;
            printf("%u", byte);
        }
    }
    printf("\n");
}

int main()
{
    long double c, r1, r2;

    r1 = expl(-1);

    c = -1;
    r2 = expl(c);

    printBits(sizeof(r1), &r1);
    printBits(sizeof(r2), &r2);

    if( memcmp( &r1, &r2, sizeof(r1)) != 0 )
    {
        printf("Not equal!\n");
        return 1;
    }

    printf("Equal!\n");
    return 0;
}

Давайте посмотрим на результат работы кода. Различаются не только run-time и compile-time варианты вычислений. На результат влияют ещё и ключи оптимизации.

Вывод при использовании компилятора x86-64 GCC 14.1:

Вывод при использовании компилятор x86-64 GCC 14.1 с ключом -O3:

Для простой параметризации типов константами этот механизм вполне можно использовать без опасений. Однако строить на них паттерн-матчинг с выбором специализаций шаблонов крайне не рекомендуется:

template <double x>
struct X {
    static constexpr double val = x;
};

template <>
struct X<+0.> {
    static constexpr double val = 1.0;
};

template <>
struct X<-0.> {
    static constexpr double val = -1.0;
};


int main() {
    constexpr double a = -3.0;
    constexpr double b = 3.0;
    std::cout << X<a + b>::val << "\n";          // печатает +1
    std::cout << X<-1.0 * (a + b)>::val << "\n"; // печатает -1
    static_assert(a + b == -1.0 * (a + b));      // ok
}

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

Полезные ссылки

Целые и вещественные числа: Integer promotion

C++ от C досталось тяжёлое наследство. Одна его часть была исправлена и беспощадно зарезана для большей надёжности: так, например, поступили с неявными const преобразованиями. Другая же часть, доставляющая не меньше проблем, перешла в первозданном виде.

В C и C++ много различных типов целых чисел разных размеров. И над ними определены операции. Правда, операции определены не для каждого типа чисел.

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

uint16_t x = 1;
uint16_t y = 2;
auto a = x - y;   // а имеет тип int
auto b = x + y;   // b имеет тип int
auto c = x * y;   // c имеет тип int
auto d = x / y;   // d имеет тип int

Хотя это опять не вся правда. Если int окажется 16-битным, то a, b, c и d станут unsigned int. Ну и стоит тип хотя бы одного аргумента поменять на uint32_t, как результат сразу же теряет знак.

Что происходит?

Происходят две неявные операции:

  • Типы, меньшие int, приводятся к int (integer promotion). Знаковому! Независимо от знаковости исходного типа!
  • Когда в операции участвуют аргументы разных типов целых чисел, они приводятся к общему типу (usual arithmetic conversion):
    • Меньший тип приводится к большему;
    • Если размеры одинаковы, то знаковый приводится к беззнаковому.

Аналогичные операции проводятся и над числами с плавающей точкой. За полной таблицей и цепочкой, показывающей, что и в кого неявно превращается, стоит обратиться к тексту стандарта.

К чему это приводит?

1. К ошибкам в логике. Неявные преобразования вовлекаются в любую операцию. Вы выполняете сравнение знакового и беззнакового числа и забыли явно привести типы? Готовьтесь к тому, что -1 < 1 может вернуть false:

std::vector<int> v = {1};
auto idx = -1;
if (idx < v.size()) {
    std::cout << "less!\n";
} else {
    std::cout << "oops!\n";
}

2. К неопределённому поведению:

unsigned short x=0xFFFF;
unsigned short y=0xFFFF;
auto z=x*y;

Integer promotion неявно приводит x и y к int, в котором происходит переполнение. Переполнение int — неопределённое поведение.

3. К трудностям в переносе программ с одной платформы на другую. Если меняется размер int/long, то применение правил неявных конверсий к вашему коду также меняется:

std::cout << (-1L < 1U);

Код выводит разные значения в зависимости от размера типа long.

Что делать?

  • Не смешивать в одном выражении знаковые и беззнаковые типы.
  • Уделять особое внимание коду, работающему с типами, меньшими int.
  • Включать предупреждения от компилятора (-Wconversion, не всегда работает).
  • Посматривать на диагностические сообщения анализаторов кода.

Полезные ссылки

Целые и вещественные числа: char и знаковое расширение

Возьмём следующую простенькую структуру:

// Пример взят и изменен отсюда:
// https://twitter.com/hankadusikova/status/1626960604412928002
struct CharTable {
    static_assert(CHAR_BIT == 8);
    std::array<bool, 256> _is_whitespace {};

    CharTable() {
        _is_whitespace.fill(false);
    }

    bool is_whitespace(char c) const {
        return this->_is_whitespace[c];
    }
};

Всё ли в порядке с этим безобидным методом is_whitespace? Ну кроме того, что char в C и C++ обычно восьмибитный, а в Unicode есть пробельные символы, кодируемые 16 битами.

Давайте потестируем:

int main() {
    CharTable table;
    char c = 128;
    bool is_whitespace = table.is_whitespace(c);
    std::cout << is_whitespace << "\n";
    return is_whitespace;
}

При сборке с -fsanitize=undefined получаем дивный результат:

/opt/compiler-explorer/gcc-12.2.0/include/c++/12.2.0/array:61:36:
runtime error: index 18446744073709551488 out of bounds for type 'bool [256]'
/opt/compiler-explorer/gcc-12.2.0/include/c++/12.2.0/array:61:36:
runtime error: index 18446744073709551488 out of bounds for type 'bool [256]'
/app/example.cpp:14:38:
runtime error: load of value 64, which is not a valid value for type 'bool'

Конкретное значение в третьей строке совершенно случайное. Было бы очень здорово стабильно видеть 42, но увы.

Зато индекс в первых двух строках совсем не случайный.

Но погодите, char c = 128, а это же точно меньше 256. Откуда 18446744073709551488?

Будем разбираться. В деле замешаны две удачно разложенные ловушки:

  • Специфичная ловушка C и C++: знаковость типа char не специфицирована. В зависимости от платформы он может быть как знаковым, так и беззнаковым. На x86 чаще всего является знаковым. И из char c = 128 получается c = -128.
  • Ловушка, распространённая во многих языках, имеющих разные типы целых чисел разной знаковости и длины. Например, Rust:
pub fn main() {
    let c : i8 = -5;
    let c_direct_cast = c as u16;
    let c_two_casts = c as u8 as u16;
    println!("{c_direct_cast} != {c_two_casts}");
}

Мы увидим 65531 != 251.

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

То же действует и в C и C++:

int main() {
    int8_t c  = -5;
    uint16_t c_direct_cast = c;
    uint16_t c_two_casts = static_cast<uint8_t>(c);
    std::cout << c_direct_cast << " != " << c_two_casts;
}

Напечатает: 65531 != 251.

А теперь остаётся только взглянуть на сигнатуру std::array::operator[]:

reference operator[]( size_type pos );

size_type — это беззнаковый size_t. Под x86 он определённо больше, чем char. Происходит прямой каст знакового char в size_t, знак расширяется, код ломается. Дело закрыто.

Что делать?

Со знаковым расширением иногда способны помочь статические анализаторы. Нужно понимать, что вы делаете при касте чисел и что хотите получить. Часто можно встретить конструкцию вида uint32_t extended_val = static_cast<uint32_t>(byte_val) & 0xFF, чтобы гарантированно занулить верхние байты и избежать знакового расширения. Аналогичная конструкция может быть и при преобразовании int32 -> uint64, и при любых других комбинациях. Только константу правильную писать не забывайте.

Из-за своей знаковой неспецифицированности тип char очень опасен при работе с ним как с типом чисел. Крайне рекомендуется пользоваться соответствующими типами uint8_t или int8_t. Или другими подходящими, если на вашей целевой платформе в char внезапно не 8 бит.

Полезные ссылки

Автор — Дмитрий Свиридкин

Более восьми лет работает в сфере коммерческой разработки высокопроизводительного программного обеспечения на C и C++. С 2019 по 2021 год преподавал курсы системного программирования под Linux в СПбГУ и практики C++ в ВШЭ. В настоящее время — Software Engineer в AWS (Cloudfront), занимается системной и embedded-разработкой на Rust и C++ для edge-серверов. Основная сфера интересов — безопасность программного обеспечения.

Редактор — Андрей Карпов

Более 15 лет занимается темой статического анализа кода и качества программного обеспечения. Автор большого количества статей, посвящённых написанию качественного кода на языке C++. С 2011 по 2021 год удостаивался награды Microsoft MVP в номинации Developer Technologies. Один из основателей проекта PVS-Studio. Долгое время являлся CTO компании и занимался разработкой С++ ядра анализатора. Основная деятельность на данный момент — управление командами, обучение сотрудников и DevRel активности.