Целое число в n битах — просто n нулей и единиц. Вопрос в том, как договориться о знаке. Исторически было три способа:
- прямой код — старший бит знак, остальные модуль. Два нуля (+0 и −0) и сложение требует отдельной логики для знаков;
- обратный код — отрицательное число получается инверсией битов. Тоже два нуля;
- дополнительный код — старшему биту приписывается вес −2ⁿ⁻¹, остальные как обычно. Ноль один, а сложение и вычитание работают теми же схемами, что и для беззнаковых чисел: результат просто берётся по модулю 2ⁿ.
Поэтому все современные процессоры используют дополнительный код, а Java, C# и Go закрепляют его в спецификации (в C++ — с C++20).
Как получить −x: инвертировать все биты и прибавить единицу. Для 8 бит: 5 = 0000 0101, инверсия 1111 1010, плюс 1 — 1111 1011 = −5. Проверка: −128 + 64 + 32 + 16 + 8 + 2 + 1 = −5.
Асимметрия диапазона. Битовых комбинаций 2ⁿ, одна занята нулём, поэтому отрицательных на одно больше: в byte от −128 до 127. Отсюда ловушка: Math.abs(Integer.MIN_VALUE) возвращает отрицательное число, потому что +2³¹ не помещается в int.
Расширение знака. При переводе в больший тип старший бит копируется влево: −5 в 16 битах — 1111 1111 1111 1011. Поэтому в Java (int) b для byte −1 даёт −1, а не 255; беззнаковое значение получают маской b & 0xFF.
Сдвиги тоже различаются: >> в Java сохраняет знак (арифметический сдвиг), >>> заполняет нулями (логический).