This commit is contained in:
Dmis
2020-12-17 22:41:53 +03:00
parent a4eac7f62f
commit 9f793b86ba
5 changed files with 318 additions and 13 deletions

181
numeric/overflow.md Normal file
View File

@@ -0,0 +1,181 @@
# Переполнение целых знаковых чисел
Большая часть написанного и еще не написанного кода любой программы так или иначе работает с числами. Вычисление по каким-либо формулам, увеличение или уменьшение счетчиков итераций циклов, рекурсивных вызовов, элементов контейнеров -- работа с числами везде.
Компьютер не может напрямую работать с бесконечно "длинными" числами -- хранить все их цифры. Как бы много оперативной памяти у нас ни было -- все же она конечна. Да и хранить, и обрабатывать величины, сопоставимые с числом атомов в видимой части вселенной -- безнадежное занятие. Так что ограничения типов `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++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
```
### Полезные ссылки
1. https://wiki.sei.cmu.edu/confluence/display/c/INT32-C.+Ensure+that+operations+on+signed+integers+do+not+result+in+overflow
2.