Files
ubbook/numeric/overflow.md
Sergey Fukanchik 8c1499929a Fix a number of typos (#128)
Co-authored-by: Sergey Fukanchik <s.fukanchik@postgrespro.ru>
2025-09-29 15:13:45 +01:00

356 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Переполнение целых знаковых чисел
Большая часть написанного и еще не написанного кода любой программы так или иначе работает с числами. Вычисление по каким-либо формулам, увеличение или уменьшение счетчиков итераций циклов, рекурсивных вызовов, элементов контейнеров — работа с числами везде.
Компьютер не может напрямую работать с бесконечно «длинными» числами — хранить все их цифры. Как бы много оперативной памяти у нас ни было — все же она конечна. Да и хранить, и обрабатывать величины, сопоставимые с числом атомов в видимой части вселенной — безнадежное занятие. Так что ограничения типов `int64` или `int128` не очень нас-то и ограничивают
Тем не менее при выполнении операций над целыми числами мы все же имеем шанс выпасть за пределы допустимого диапазона (например, `[-2^31, 2^31-1]` для `int32`), и тут в игру вступают особенности поддержки целых чисел для того или иного языка программирования, а также, быть может, особенности реализации конкретной платформы.
При выполнении инструкции `add` (`iadd`) платформы х86 переполнение целого числа сопровождается выставлением специального флага переполнения, а результирующее значение просто получается отбрасыванием старшего бита результата. И следует ожидать, что по окончании работы условной программы
```
x = 2^31 - 1
iadd x 5
```
произойдет перенос разряда в знаковый бит, и переменная `x` примет отрицательное значение.
В реализации конкретного языка программирования может быть проверка флага переполнения и сообщение об ошибке. А может и не быть. Может быть гарантия «цикличности» значений (после `2^31-1` идет `-2^31`), а может и не быть.
Проверки и гарантии — это дополнительные инструкции, которые нужно генерировать компилятору, а процессору потом исполнять.
В языке C++ решили не жертвовать производительностью и заставлять компиляторы генерировать код проверки, а объявили переполнение целых знаковых (`signed`) чисел неопределенным, открывая простор для оптимизаций. Компилятор может генерировать любой код, какой ему вздумается, ориентируясь лишь на одно правило: переполнения не бывает.
Многие программисты свято верят, что переполнение чисел работает, как ожидается, «циклично» — и пишут проверки вида
```C++
if (x > 0 && a > 0 && x + a <= 0) {
// обработай переполнение
}
```
Но, увы, это неопределенное поведение. И компилятор имеет полное право [выкинуть](https://godbolt.org/z/dhs83T) такую проверку.
Искусственный пример может быть недостаточно убедительным, так что обратим внимание на следующую, вполне серьезную, функцию вычисления полиномиального хэша строки:
```C++
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;
}
```
Функция, которая никогда не должна, по задумке, возвращать отрицательные числа, таки [выдает](https://godbolt.org/z/4v139E) отрицательное число! Из-за неопределенного поведения и бессмысленной с точки зрения компилятора проверки.
Другой замечательный, но искусственный пример, для большего устрашения: [конечный цикл может стать бесконечным!](https://godbolt.org/z/Y6bTP3MK3)
```C++
// пример взят из блога https://mohitmv.github.io/blog/Shocking-Undefined-Behaviour-In-Action/
int main() {
char buf[50] = "y";
for (int j = 0; j < 9; ++j) {
std::cout << (j * 0x20000001) << std::endl;
if (buf[0] == 'x') break;
}
}
```
Компилятор выполняет удивительную оптимизацию умножения константы на последовательные числа, полностью изменяя заголовок цикла и условия остановки:
```C++
for(int j = 0; j < 9*0x20000001; j += 0x20000001) {
...
}
```
а `j < 9*0x20000001` всегда истинно так как правая часть больше чем `std::numeric_limits<int>::max()`.
С современными версиями компиляторов этот пример особенно занятен. GCC в подобных циклах иногда способны заметить переполнение и выдать предупреждение. Но этого не произошло...
Однако если мы закомментируем недостижимый `break` и `buf` мы [получим](https://godbolt.org/z/Wszeb4a6s)
```
<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`, то предупреждение [пропадет](https://godbolt.org/z/cK483MnP3) (GCC 13.2)
----
Другой, возможно, более известный и иногда полезный пример оптимизации, которую такое неопределенное поведение упрощает для компилятора -- сворачивать известные суммы.
Например, при суммировании арифметических прогрессий и некоторых других известных рядов, Clang 12, генерирует совершенно разный код для знаковых и беззнаковых чисел:
```C++
// https://godbolt.org/z/oE7q6WjTv
// суммируем квадраты от 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;
}
/* Tут нет цикла: используется известная формула (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 год)* в принципе [не делает](https://godbolt.org/z/xjf7zj768) таких оптимизаций по умолчанию. При этом последние версии Clang 18
уже способны свернуть цикл суммирования квадратов и для беззнаковых:
```asm
# https://godbolt.org/z/WqaeaPjfe
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, безопасный обобщенный код арифметических операций над целыми знаковыми числами мог бы выглядеть так
```C++
#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_](https://en.wikipedia.org/wiki/Two%27s_complement)), и многие проблемы сдвигов сняты. Тем не менее все равно стоит следовать общей рекомендации: любые битовые операции выполнять только в `unsigned` типах.
Стоит заметить, что сужающее преобразование из целочисленного типа в другой целочисленный тип к неопределенному поведению не приводит, и выполнять побитовое `и` с маской перед присваиванием переменной меньшего типа не обязательно. Но желательно, чтобы избежать предупреждений компилятора
```C++
constexpr int x = 12345678;
constexpr uint8_t first_byte = x; // Implicit cast. Warning
```
Очень неприятным является переполнение целых, возникающее из-за правил `integer promotion`:
```C++
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` в этом [примере](https://godbolt.org/z/GWsaGo) возникает переполнение знакового! числа и вытекающее из этого UB. Справедливости ради, надо заметить, что такое происходит только на платформах, где размер `int` больше `uint16_t` (то есть практически везде в наши дни).
```C++
x *= x; // переписывается как x = x * x;
// тип uint16 меньше чем тип int — для * выполняется неявное приведение к int.
```
### Полезные ссылки
1. https://wiki.sei.cmu.edu/confluence/display/c/INT32-C.+Ensure+that+operations+on+signed+integers+do+not+result+in+overflow
2. https://stackoverflow.com/a/46073296
3. https://habr.com/ru/post/307702/