Почему целые числа удивляют
У машинного целого фиксированное число битов, поэтому любая операция может дать значение, которое не помещается. Что происходит дальше, решает не аппаратура (она просто заворачивает значение по кругу), а язык:
| Язык | Знаковое переполнение | Беззнаковое переполнение | Сдвиг на ≥ разрядности | Деление на ноль |
|---|---|---|---|---|
| C / C++ | неопределённое поведение: оптимизатор считает, что этого никогда не происходит | заворачивается по модулю 2ⁿ (определено) | не определено | не определено (SIGFPE на x86) |
| Rust | panic в debug, заворачивание в release; checked_/wrapping_/saturating_/overflowing_ задаются явно | как у знакового | panic в debug | panic |
| Go | заворачивается (определено) | заворачивается | 0 / −1, без panic | panic |
| Java | заворачивается (определено); Math.addExact бросает исключение | беззнаковых типов нет (char — 16-битный беззнаковый) | величина сдвига маскируется до 5/6 бит | ArithmeticException |
| JavaScript | числа — это double: точны до 2⁵³, дальше округление; побитовые операторы работают с int32 | величина сдвига маскируется до 5 бит | Infinity / NaN | |
Правила C, на которых спотыкаются
- Продвижение целых типов. Всё, что меньше
int, перед арифметикой становитсяint, поэтомуuint8_t a = 200, b = 100; a + bравно 300, и только присваивание обратно вuint8_tусекает до 44.~xдляuint8_tдаёт отрицательный int. - Обычные арифметические преобразования. Когда знаковый тип встречается с беззнаковым той же разрядности, знаковая сторона становится беззнаковой:
-1 > 0uистинно,if (len - 1 < 0)никогда не истинно, еслиlenимеет типsize_t. - Знаковое переполнение — это UB.
if (x + 1 < x)в качестве проверки переполнения удаляется GCC и Clang при включённой оптимизации, потому что они вправе считать, чтоx + 1никогда не переполняется. Проверяйте до операции (x > INT_MAX - y) или используйте__builtin_add_overflow. - Сдвиги. Сдвиг на разрядность или больше, а также сдвиг влево отрицательного значения не определены; x86 маскирует величину сдвига, ARM даёт ноль, компилятор делает то, что предписывает его модель.
- Деление. Усекает к нулю, поэтому
-7 / 2 == -3и-7 % 2 == -1;INT_MIN / -1переполняется и на x86 действительно вызывает trap.
Как читать вывод
Результат показан в том типе, в котором он оказывается, с его битовым шаблоном и теми же битами, прочитанными с противоположной знаковостью, — именно это вы видите, когда значение пересекает границу (int) / (unsigned) или сохраняется и читается через другой тип. Список шагов — это порядок вычисления с типом на каждом шаге; примечания — это места, где язык сделал что-то помимо обычной арифметики.
Вопросы
Почему результат для C отличается от того, что напечатала моя программа?
Для неопределённого поведения нет единственно правильного ответа: эта страница показывает значение с заворачиванием, которое даёт неоптимизированный код, и помечает шаг как UB. С -O2 компилятор мог предположить, что переполнение невозможно, и сгенерировать другой код. Скомпилируйте с -fsanitize=undefined, чтобы увидеть точное место.
Что делает выбор типа?
Это тип литералов без суффикса или приведения, который в C по умолчанию int. Выберите u8 и введите 200 + 100, чтобы увидеть panic в Rust или продвижение до int и успешный результат в C.
Как записать приведение типа?
В стиле C: (u8) expr, (uint32_t) expr, (int) expr. В стиле Rust: expr as u8. Оба варианта работают в любом языковом режиме. INT_MAX, INT_MIN, UINT_MAX, LONG_MAX, SIZE_MAX доступны как имена.
Поддерживаются ли 128 бит?
Да: i128 и u128 вычисляются точно (вся арифметика выполняется с произвольной точностью, а затем результат подгоняется под тип).
А что с плавающей точкой?
Не здесь; этим занимается визуализатор float IEEE 754 на этом сайте. Этот инструмент только о том, что происходит с целыми числами в типах фиксированной разрядности.