# Переполнение целых знаковых чисел Большая часть написанного и еще не написанного кода любой программы так или иначе работает с числами. Вычисление по каким-либо формулам, увеличение или уменьшение счетчиков итераций циклов, рекурсивных вызовов, элементов контейнеров — работа с числами везде. Компьютер не может напрямую работать с бесконечно «длинными» числами — хранить все их цифры. Как бы много оперативной памяти у нас ни было — все же она конечна. Да и хранить, и обрабатывать величины, сопоставимые с числом атомов в видимой части вселенной — безнадежное занятие. Так что ограничения типов `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::max(); return h; } ``` Функция, которая никогда не должна, по задумке, возвращать отрицательные числа, таки [выдает](https://godbolt.org/z/4v139E) отрицательное число! Из-за неопределенного поведения и бессмысленной с точки зрения компилятора проверки. Корректные проверки переполнения куда сложнее и тяжелее. Так, для C++20, безопасный обобщенный код арифметических операций над целыми знаковыми числами мог бы выглядеть так ```C++ #include #include #include #include namespace safe { // Все эти проверки справедливы только для целых знаковых чисел template concept SignedInteger = std::is_signed_v && std::is_integral_v; enum class ArithmeticError { Overflow, ZeroDivision }; template using ErrorOrInteger = std::variant; template ErrorOrInteger add(I a, // выключаем вывод параметра шаблона по std::type_identity_t b) // второму аргументу { if (b > 0 && a > std::numeric_limits::max - b) { // положительное переполнение return ArithmeticError::Overflow; } if (b < 0 && a < std::numeric_limits::min - b) { // отрицательное переполнение return ArithmeticError::Overflow; } return a + b; } template ErrorOrInteger sub(I a, std::type_identity_t b) { if (b < 0 && a > std::numeric_limits::max + b) { // положительное переполнение return ArithmeticError::Overflow; } if (b > 0 && a < std::numeric_limits::min + b) { // отрицательное переполнение return ArithmeticError::Overflow; } return a - b; } template ErrorOrInteger mul(I a, std::type_identity_t b) { if (a == 0 || b == 0) { return 0; } if (a > 0) { if (b > 0) { if (a > std::numeric_limits::max / b) { return ArithmeticError::Overflow; } } else { if (b < std::numeric_limits::min / a) { return ArithmeticError::Overflow; } } } else { if (b > 0) { if (a < std::numeric_limits::min / b) { return ArithmeticError::Overflow; } } else { if (b < std::numeric_limits::max / a) { return ArithmeticError::Overflow; } } } return a * b; } template ErrorOrInteger div(I a, std::type_identity_t b) { if (b == 0) { return ArithmeticError::ZeroDivision; } if (a == std::numeric_limits::min && b == -1) { // диапазон [min, max] несимметричный относительно 0. // abs(min) > max — будет переполнение return ArithmeticError::Overflow; } return a / b; } template ErrorOrInteger mod(I a, std::type_identity_t b) { if (b == 0) { return ArithmeticError::ZeroDivision; } if (b == -1) { // По стандарту в этом случае также неопределенное поведение при // a == std::numeric_limits::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/