Вашему вниманию предлагается вторая часть электронной книги, которая посвящена неопределённому поведению. Книга не является учебным пособием и рассчитана на тех, кто уже хорошо знаком с программированием на 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;
}
Функция, которая по задумке никогда не должна возвращать отрицательные числа, таки выдаёт отрицательное число! Из-за неопределённого поведения и бессмысленной с точки зрения компилятора проверки.
Компилятор может руководствоваться следующей логикой:
Другой замечательный, но искусственный пример, для большего устрашения: конечный цикл может стать бесконечным! Пример взят из публикации "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 определяется как -INF, NaN, или 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
}
По тем же причинам ни в одном языке программирования не рекомендуется использовать значения с плавающей точкой в качестве ключей ассоциативных массивов.
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, как результат сразу же теряет знак.
Происходят две неявные операции:
Аналогичные операции проводятся и над числами с плавающей точкой. За полной таблицей и цепочкой, показывающей, что и в кого неявно превращается, стоит обратиться к тексту стандарта.
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.
Возьмём следующую простенькую структуру:
// Пример взят и изменен отсюда:
// 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?
Будем разбираться. В деле замешаны две удачно разложенные ловушки:
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 активности.