Как работает двоичная арифметика: целые в дополнительном коде и числа IEEE-754
Каждая числовая операция, которую выполняет ваша программа — a + b, x * y, count / 2 — в конечном счёте превращается в последовательность побитовых операций над битовыми последовательностями. А то, как эти побитовые операции дают верный арифметический результат, целиком зависит от того, какую кодировку используют биты.
Для целых чисел компьютеры используют дополнительный код — прекрасно простую схему, в которой знаковое сложение выполняется на том же самом аппаратном сумматоре, что и беззнаковое: биты не знают и не заботятся о том, считали ли вы их знаковыми. Для дробей компьютеры используют плавающую точку IEEE-754 — гораздо более изощрённый механизм с отдельными полями знака, порядка и мантиссы, которые приходится выравнивать и округлять при каждой операции.
Эта статья проходит арифметику и того, и другого: что на самом деле происходит с битами при сложении, вычитании, умножении и делении в каждом формате, где операции ломаются и чем эти две системы отличаются. Сама кодировка каждого формата разобрана в соседних статьях:
- Смещённый двоичный код против дополнительного: два способа представлять знаковые числа — как дополнительный код (и его родственник, смещённый двоичный) кодируют знаковые значения.
- Как Number в JavaScript и float в Python хранят числа — как IEEE-754 упаковывает знак, порядок и мантиссу в 64 бита.
Здесь мы сосредоточимся именно на самих арифметических операциях, с короткими напоминаниями о кодировке там, где это нужно.
Арифметика целых в дополнительном коде
Будем считать, что основы кодировки вы уже знаете: старший бит (MSB) несёт отрицательный вес, чтобы получить противоположное число, нужно инвертировать биты и прибавить 1, а диапазон асимметричен на единицу (от -8 до +7 в 4 битах или от -2 147 483 648 до +2 147 483 647 в 32 битах). Если что-то из этого незнакомо, полный разбор есть в статье о смещённом двоичном коде против дополнительного. Примеры на 4 битах ниже — просто заменитель для любой разрядности n.
Сложение и вычитание: как у беззнаковых
Первое прекрасное свойство дополнительного кода: знаковое сложение использует тот же физический сумматор, что и беззнаковое.
Дополнительный код хранит каждое отрицательное число той битовой последовательностью, которая, прочитанная как беззнаковая, равна — например, в 4 битах () число хранится как последовательность для 13: , то есть 1101 в двоичном виде. Из-за этого сложение отрицательного и положительного числа часто переполняет -битный регистр, но аппаратура просто выбрасывает исходящий перенос — и знаковый ответ получается сам собой.
Посмотрим на сложение с отрицательным числом в действии. Проследим -3 + 5 в 4 битах: -3 кодируется как 1101 (= 13), 5 — это 0101:
1 1 0 1 (-3, хранится как 13)
+ 0 1 0 1 (+5)
─────────
1 0 0 1 0 (= 18; не влезает в 4 бита — ведущая 1 это перенос, вес 2^4 = 16)
0 0 1 0 (остаётся в 4-битном регистре: 2) ✓ совпадает с -3 + 5 = 2Аппаратура просто сложила беззнаковые битовые последовательности 13 + 5 = 18. Бит переноса на 5-й позиции (вес ) не влез в 4-битный регистр и был отброшен.
Эти отброшенные 16 — ровно та часть из формулы , которую мы видели выше: смещение кодировки и переполнение взаимно уничтожаются, и знаковый ответ получается бесплатно. Теперь мы просто интерпретируем эти биты как знаковые, тогда как аппаратный механизм такого различия не делает. Та же логика покрывает случаи без переполнения: -7 + 4 — это 1001 + 0100 = 1101, что декодируется как -3 (знаковое) или 13 (беззнаковое).
Поскольку вычитание — это просто сложение с противоположным числом, отдельная схема ему тоже не нужна: a - b = a + (-b), где взятие противоположного — это рецепт «инвертировать биты и прибавить 1». Для 5 - 3: -3 — это 1101, тогда 0101 + 1101 = 10010. Ведущий бит в 4 битах отпадает, остаётся 0010 = 2 — и этот отброшенный бит есть модульный перенос через край.
Полная картина (-битная арифметика как окружность из позиций) разобрана в разделе о модульной арифметике в статье о кодировке.
Механизм по модулю приводит к такой обработке переполнений: INT_MAX + 1 = INT_MIN, а INT_MIN - 1 = INT_MAX — это не баг, а просто модульный шаг, пересекающий стык между положительной и отрицательной половинами. Заметьте, что INT_MAX и INT_MIN — это принятые в C/C++ имена для знаковых 32-битных крайностей и ; в Java есть Integer.MAX_VALUE/MIN_VALUE, в Rust — i32::MAX/MIN. В Python и JavaScript их нет, поскольку их целочисленные типы произвольной точности.
Сравнение: где одних бит недостаточно
В отличие от сложения, сравнение всё-таки зависит от интерпретации. Битовая последовательность 1111 как беззнаковая равна 15, так что естественно 1111 > 0001 (15 > 1). Однако если те же самые биты подразумеваются как знаковое -1, то правильный ответ — -1 < 1, а побитовое сравнение всё равно даёт 1111 > 0001. Те же биты, противоположные ответы.
На уровне аппаратуры сравнение часто реализовано как вычитание: процессор вычисляет a - b и выставляет флаги (знак, ноль, переполнение, перенос); команды ветвления затем читают эти флаги, чтобы решить, куда переходить. Само вычитание использует тот же механизм a + (-b), что и выше — процессор просто выбрасывает результат и оставляет только флаги. Несколько 4-битных примеров, чтобы увидеть каждый флаг в действии:
5 - 3: 0101 - 0011 = 0010 carry=0, sign=0, zero=0, overflow=0
3 - 5: 0011 - 0101 = 1110 carry=1, sign=1, zero=0, overflow=0 (= -2 знаковое)
5 - 5: 0101 - 0101 = 0000 carry=0, sign=0, zero=1, overflow=0
-8 - 1: 1000 - 0001 = 0111 carry=0, sign=0, zero=0, overflow=1 (= -9, не влезает)Возьмём первую строку, 5 - 3 = 2, где все флаги равны 0: carry = 0, потому что беззнаковое вычитание остаётся в диапазоне (5 ≥ 3); sign = 0, потому что старший бит результата (0010) равен 0; zero = 0, потому что результат не нулевой; overflow = 0, потому что +2 легко влезает в знаковый 4-битный диапазон. В остальных строках переключается по одному флагу: carry срабатывает, когда беззнаковое вычитание уходит за ноль (строка 2: 3 < 5, так что результат — это представление отрицательного числа в дополнительном коде); sign срабатывает, когда старший бит результата равен 1 (1110 во строке 2); zero срабатывает, когда операнды были равны (строка 3); overflow срабатывает, когда знаковый результат выходит за знаковый 4-битный диапазон (строка 4: -8 - 1 = -9 уходит ниже минимума -8).
Поскольку одни и те же биты могут означать разное как знаковые и как беззнаковые, процессоры предоставляют два семейства команд ветвления — знаковые (JL, JG в x86 — «перейти, если меньше/больше») и беззнаковые (JB, JA — «перейти, если ниже/выше»), — каждое из которых читает свою комбинацию флагов:
| Сравнение | Беззнаковое | Знаковое |
|---|---|---|
| a < b | JB: carry = 1 | JL: sign ⊕ overflow = 1 |
| a > b | JA: carry = 0 ∧ zero = 0 | JG: sign ⊕ overflow = 0 ∧ zero = 0 |
| a ≤ b | JBE: carry = 1 ∨ zero = 1 | JLE: sign ⊕ overflow = 1 ∨ zero = 1 |
| a ≥ b | JAE: carry = 0 | JGE: sign ⊕ overflow = 0 |
В этих условиях ⊕ — это логическое исключающее ИЛИ (ровно один вход равен 1), ∧ — логическое И (оба входа равны 1), а ∨ — логическое ИЛИ (хотя бы один вход равен 1).
Компилятор выбирает нужный вариант по объявленному типу.
В действии, снова на 4 битах (условие по флагу в каждой строке выполнено, так что переход происходит):
JB (1 < 2 беззнак.): 0001 - 0010 = 1111 carry = 1
JA (3 > 2 беззнак.): 0011 - 0010 = 0001 carry = 0, zero = 0
JL (-1 < 1 знак.): 1111 - 0001 = 1110 sign ⊕ overflow = 1 ⊕ 0 = 1
JG (3 > 2 знак.): 0011 - 0010 = 0001 sign ⊕ overflow = 0 ⊕ 0 = 0, zero = 0Правая часть каждой строки читается как формула → подставленные значения флагов → логический результат. Возьмём строку JL: у вычитания 1111 - 0001 = 1110 получается sign = 1 (старший бит результата) и overflow = 0 (знаковое значение -2 всё ещё влезает в 4 бита), так что sign ⊕ overflow превращается в 1 ⊕ 0, что даёт 1 — условие срабатывания JL. Строка JG работает так же с sign = 0 и overflow = 0, давая 0 ⊕ 0 = 0 (плюс zero = 0), что и есть условие срабатывания JG.
Равенство (a == b, команда JE) и его отрицание (JNE) просто читают флаг нуля и одинаковы для знаковых и беззнаковых.
Умножение: сдвигай и складывай
Вспомните умножение в столбик из школы: для каждой цифры одного операнда умножаем её на другой операнд, сдвигаем получившуюся строку на позицию этой цифры и складываем все строки. Например, 123 × 456:
1 2 3 (a = 123)
× 4 5 6 (b = 456)
─────────
7 3 8 ← 123 × 6 (6 на позиции 0, сдвига нет)
6 1 5 ← 123 × 5, сдвинуто на одну позицию влево (5 на позиции 1)
4 9 2 ← 123 × 4, сдвинуто на две позиции влево (4 на позиции 2)
─────────
5 6 0 8 8 = 56088В двоичном виде всё работает так же, но с одним большим упрощением: каждая цифра множителя — это либо 0, либо 1. Поэтому каждая строка — это либо сдвинутая копия множимого a (когда бит множителя равен 1), либо просто ноль (когда бит равен 0), и никакого поцифрового умножения, как в десятичной системе, не требуется. Это как если бы всякое умножение выполнялось на десятичное число, у которого все цифры — только 1 и 0, вроде 25 × 101:
2 5 (a = 25)
× 1 0 1 (b = 101)
─────────
2 5 ← цифра 0 = 1: прибавляем 25
0 0 ← цифра 1 = 0: пропускаем
2 5 ← цифра 2 = 1: прибавляем 25, сдвинутое на 2 влево (= 2500)
─────────
2 5 2 5 = 2525Обратите внимание: никакого настоящего умножения цифр здесь нет — каждая строка это либо 25, сдвинутое на свою позицию, либо просто 0. А сам сдвиг — это просто дописывание нулей справа: 25, сдвинутое на 2 влево, это 2500, с двумя приписанными нулями (в двоичном виде тот же сдвиг превращает 0011 в 1100).
Так вся операция сводится к «либо прибавить дополненную нулями копию a, либо пропустить».
Именно это свойство и делает двоичное умножение таким дешёвым: в двоичной системе у множителя цифры всегда из {0, 1}, так что каждая строка сводится к одному и тому же выбору «сдвиг или ноль», каким бы ни был операнд.
Вот 3 × 5 (0011 × 0101) в 4 битах, прогнанное через тот же алгоритм:
0 0 1 1 (a = 3)
× 0 1 0 1 (b = 5)
─────────
0 0 1 1 ← бит 0 = 1: прибавляем a
0 0 0 0 ← бит 1 = 0: пропускаем
0 0 1 1 ← бит 2 = 1: прибавляем a ≪ 2
0 0 0 0 ← бит 3 = 0: пропускаем
─────────────────
0 0 0 0 1 1 1 1 = 15Построчная раскладка наводит на мысль о n последовательных шагах — но процессору не нужно, чтобы они были последовательными. Каждая строка зависит только от a и от одного бита b, а не от результата какой-либо предыдущей строки, так что все n строк можно вычислить параллельно. Чтобы получить строку i, аппаратура сдвигает a влево на i (позиция этой строки), а затем либо оставляет сдвинутое значение (если b[i] равен 1), либо заменяет его нулями (если b[i] равен 0).
Затем n частичных произведений суммируются параллельным деревом сумматоров (Уоллеса или Дадда), что занимает задержки вентилей — в противоположность такта у последовательного умножителя со сдвигом и сложением.
В аппаратуре это превращается в параллельное дерево: b расщепляется на биты, каждый бит — это ветвь, и каждая ветвь решает 0 или 1. Если 1, эта ветвь вносит a, сдвинутое на свою позицию; если 0, вносит ноль.
Все ветви работают параллельно, а затем дерево сумматоров их складывает.
Для a = 0011 (= 3), b = 0101 (= 5) процесс вычисления выглядит так:
a = 0011 (раздаётся в каждую ветвь)
│
┌───────────┼───────────┬───────────┬───────────┐
│ │ │ │ │
▼ ▼ ▼ ▼
a ≪ 0 a ≪ 1 a ≪ 2 a ≪ 3 ← сдвиг на индекс ветви
= 00000011 = 00000110 = 00001100 = 00011000 (результат в 8-битном поле)
│ │ │ │
× b₀=1 × b₁=0 × b₂=1 × b₃=0 ← вентиль по этому биту b
│ │ │ │ (если bᵢ=0, выход = 0;
▼ ▼ ▼ ▼ если bᵢ=1, выход = a≪i)
00000011 00000000 00001100 00000000
(partial₀) (partial₁) (partial₂) (partial₃)
│ │ │ │
└───────────┴─────┬─────┴───────────┘
│
▼
┌────────────────────────────┐
│ parallel adder tree │ ← глубина вентилей O(log n)
│ sum = 00001111 = 15 │
└──────────────┬─────────────┘
│
▼
P = 00001111 = 15 ← 2n-битное произведение (= a × b)Есть практическая ловушка, о которой стоит знать: умножение может незаметно переполниться, даже когда операнды и близко не подходят к максимуму типа. Так происходит потому, что произведению нужно бит, а не : для 4 бит × 4 бита наибольшее произведение — это , что выливается в 8 бит; для 8 бит × 8 бит требует 16 бит. Разные языки справляются с этим по-разному:
| Стратегия | Поведение при переполнении | Пример |
|---|---|---|
| Молчаливое усечение | Оставить младшие n бит, старшую половину отбросить | C/Java int * int → int; Rust i32 * i32 в release |
| Более широкий тип | Сначала расширить операнды, чтобы влезли все 2n бит | C: (int64_t)a * (int64_t)b; Java: (long)a * b |
| С проверкой / ловушкой | Обнаружить переполнение, вернуть ошибку или паниковать | Rust a.checked_mul(b) → Option; в debug-режиме * паникует |
| С насыщением | Зажать в MAX/MIN типа вместо переноса | Rust a.saturating_mul(b) |
| Произвольная точность | Использовать растущий тип — переполнения нет вовсе | Python int, JavaScript BigInt, Java BigInteger |
Знаковое умножение
В отличие от сложения, для умножения знаковых и беззнаковых один и тот же алгоритм так прямолинейно не работает. Дело в том, что в дополнительном коде старший бит множителя несёт отрицательный вес — поэтому когда он равен 1, вклад этой строки нужно вычитать, а не прибавлять. Например, при b = 1110 и a = 3: прочитанное как беззнаковое, b = 14, и все строки прибавляются (0 + 6 + 12 + 24 = 42, что совпадает с 3 × 14); прочитанное как знаковое, b = -2, и строка старшего бита вместо этого вычитается (0 + 6 + 12 − 24 = -6, что совпадает с 3 × -2). Те же биты операнда, у одной строки перевёрнут знак.
Так что алгоритму выше нужна пара доработок: вычитать (а не прибавлять) частичное произведение для строки знакового бита и расширять по знаку каждое частичное произведение до полной ширины бит, чтобы отрицательные вклады корректно распространялись в старшую половину. Прогоняем 3 × -2 (0011 × 1110) по этому рецепту:
0 0 1 1 (a = 3)
× 1 1 1 0 (b = -2 знаковое)
─────────
0 0 0 0 0 0 0 0 ← бит 0 = 0: пропускаем
0 0 0 0 0 1 1 0 ← бит 1 = 1: прибавляем a ≪ 1
0 0 0 0 1 1 0 0 ← бит 2 = 1: прибавляем a ≪ 2
1 1 1 0 1 0 0 0 ← бит 3 = 1 (MSB!): вычитаем a ≪ 3 (= -24, в 8-битном дополнительном коде)
─────────────────
1 1 1 1 1 0 1 0 = -6 (знаковое) ✓Расхождение знакового и беззнакового живёт целиком в старших битах полного -битного произведения — младшие бит одинаковы в любом случае. Например, с теми же битами операндов 0011 × 1110:
беззнак. (3 × 14 = 42): 0 0 1 0 | 1 0 1 0
знак. (3 × -2 = -6): 1 1 1 1 | 1 0 1 0
─────── ───────
старшие n младшие n
(различны) (идентичны)Вот почему умножению одинаковой ширины (int * int → int) не нужны отдельные знаковые и беззнаковые варианты на уровне языка: это просто младшие n бит, которые при переполнении заворачиваются, как при сложении (без особого патологического случая вроде INT_MIN / -1, как это бывает у деления).
Деление: сдвиг и вычитание
Теперь посмотрим на деление. На уровне бит в нём нет ничего экзотического — это просто деление в столбик, применённое к двоичному виду.
Вспомните школьное деление в столбик, скажем 105 ÷ 7:
1 5
─────
7 │ 1 0 5
7
─────
3 5
3 5
─────
0Чтобы вычислить, мы идём по цифрам делимого (105) слева направо. На каждом шаге спрашиваем: «сколько раз делитель (7) влезает в то, что у нас пока есть?», вычитаем столько раз, сносим следующую цифру и повторяем.
Двоичное деление работает точно так же, только у вопроса «сколько раз?» есть всего два возможных ответа: 0 или 1 — делитель либо влезает один раз, либо не влезает вовсе. Это и есть ключевое упрощение.
В десятичной системе каждый шаг требует настоящего вычисления (или оценки), чтобы определить цифру от 0 до 9 — нужно выяснить, сколько делителя влезает, а это в общем случае требует умножения и проб с ошибками.
В двоичной системе вычислять нечего: одно сравнение текущего остатка с делителем сразу даёт ответ — 1, если делитель влезает, 0, если нет.
Ни таблицы умножения, ни проб — только одно решение «вычитать или нет» на каждый бит. Вот почему двоичное деление в столбик так чисто ложится на аппаратуру: внешний цикл перебирает биты делимого, а каждая итерация — это всего лишь компаратор и условное вычитание, и больше ничего.
А из раздела о сравнении вспомним, что компаратор реализован как вычитание: R ≥ D — это просто R − D с проверкой флага знака или переноса.
Так что на каждой итерации цикла аппаратура спекулятивно вычисляет R − D: если результат неотрицателен (заёма нет), она фиксирует его обратно в R и записывает 1 в Q; иначе выбрасывает результат и записывает 0. Одно вычитание на итерацию, всегда.
Процессор держит два регистра: R хранит текущий остаток, Q побитово накапливает частное.
На каждом шаге он сдвигает R влево и вносит следующий бит делимого, а затем сравнивает R с D, всегда вычисляя R − D. В зависимости от результата:
- если
R − Dнеотрицателен (Dвлезает) → выставляет новый битQв1и заменяетRэтим результатом - если
R − Dотрицателен (Dне влезает) → выставляет новый битQв0, отбрасывает результат и оставляетRбез изменений
Посмотрим на примере: 13 ÷ 3 в 4 битах, делимое , делитель .
Пройдите пошагово по виджету ниже, чтобы увидеть оба изображения параллельно — школьное деление в столбик слева и трассировку сдвиговых регистров процессора справа.
Виджет показывает четыре строки регистров: Ds — это делитель (то, что в тексте мы называли D), Dd — делимое, R — текущий остаток, а Q — частное, которое строится бит за битом. Мысленная модель: Dd — это входной поток бит, подаваемый в R (на каждом такте вдвигается следующий бит делимого); Ds — неподвижный эталон, с которым сравнивается R; R и Q — рабочие регистры, оба сдвигаются влево на каждом такте.
У виджета выше 14 состояний — единообразная структура из 3 подэтапов на такт (сдвиг R, сравнение/вычитание, сдвиг Q). Трассировка ниже повторяет пошаговый текст, который показывается в информационной панели виджета:
Step 0 — Init. Ds = 0011 (3), R = 0000, Q = 0000.
Такт 1 — бит 3 = 1 (вычитания нет)
Step 1 — (a) сдвиг R: вдвинут бит 3 → R = 0001. Сравнения пока нет.
Step 2 — (b) сравнение: 1 < 3 → вычитания нет. Подсвечена ветвь «R < D».
Step 3 — (c) сдвиг Q: Q = 0000. Первая цифра частного = 0.
Такт 2 — бит 2 = 1 (вычитание срабатывает)
Step 4 — (a) сдвиг R: вдвинут бит 2 → R = 0011 (промежуточное, видно).
Step 5 — (b) сравнение: 3 ≥ 3 → подсвечена ветвь «R ≥ D»; вычитание произойдёт по следующему клику.
Step 6 — (c) вычитание + сдвиг Q: R = 0011 − 0011 = 0000; Q = 0001. На школьной стороне появляется строка «1 1».
Такт 3 — бит 1 = 0 (вычитания нет)
Step 7 — (a) сдвиг R: вдвинут бит 1 → R = 0000.
Step 8 — (b) сравнение: 0 < 3 → вычитания нет.
Step 9 — (c) сдвиг Q: Q = 0010. Третья цифра частного = 0.
Такт 4 — бит 0 = 1, младший (вычитания нет)
Step 10 — (a) сдвиг R: вдвинут бит 0 → R = 0001.
Step 11 — (b) сравнение: 1 < 3 → вычитания нет.
Step 12 — (c) сдвиг Q: Q = 0100. Четвёртая цифра частного = 0.
Step 13 — Done. Q = 0100 (= 4), R = 0001 (= 1). 13 ÷ 3 = 4, остаток 1.После n шагов (по одному на бит) у вас есть полное частное. Какими бы Q и R процессор ни оказался в конце прохода, они по построению удовлетворяют жёсткому тождеству:
Это тождество не какое-то дополнительное правило — это само определение остатка: R есть то, что осталось после того, как цикл вычел D столько раз, сколько было возможно. Полученные виджетом Q = 4, R = 1 — тривиальная проверка: 4 · 3 + 1 = 13. Сдвиг с вычитанием выдаёт и a / b, и a % b за один проход — оба результата получаются вместе.
Реализации сохраняют это тождество везде, где Q и R оба влезают в регистр. Когда истинное частное не является целым, его приходится округлять — и языки идут двумя путями:
- Усечение к нулю (C99/C++, Rust, Java, Go):
a / bокругляется к нулю; остаток наследует знак делимого. - Деление с округлением вниз (Python):
a // bокругляется к ; остаток наследует знак делителя.
Пример с a = -17, b = 5 (истинное частное равно ):
C: -17 / 5 = -3 -17 % 5 = -2 (-3) * 5 + (-2) = -17 ✓
Python: -17 // 5 = -4 -17 % 5 = 3 (-4) * 5 + 3 = -17 ✓Оба удовлетворяют тождеству; различаются они только направлением округления. Приведённый выше алгоритм сдвига с вычитанием даёт усечённое деление напрямую — он работает с абсолютными величинами и возвращает знак на границе. Деление с округлением вниз — это небольшая поправка после: если знаки операндов различаются и остаток не нулевой, уменьшить частное на 1 и прибавить b к остатку.
Как мы только что видели, алгоритм сдвига с вычитанием делает по одной итерации цикла на бит, что делает целочисленное деление операцией O(n) для n-битных чисел — заметно медленнее сложения или умножения, поэтому даже современные процессоры тратят много такта на команду idiv. Более простые архитектуры иногда реализуют его программным циклом, а не выделенной аппаратурой, тогда как современные процессоры обходятся без простого сдвига с вычитанием в пользу более быстрых алгоритмов — деления SRT, Ньютона — Рафсона и Голдшмидта, — которые сводят деление к горстке умножений, поскольку умножение в аппаратуре гораздо дешевле.
Знаковое деление — это тот же алгоритм, прогнанный по абсолютным величинам операндов, со правкой знаков до и после цикла: частное меняет знак, если ровно один операнд был отрицательным, а остаток берёт знак делимого (C/C++) или делителя (Python).
Это чисто работает для любой пары — кроме одного крайнего случая.
Единственное знаковое деление, которое переполняется
Переполнения при сложении исправимы в том смысле, что перенос через край хорошо определён. Но в дополнительном коде есть ровно одна арифметическая операция, у которой вообще нет осмысленного ответа: деление минимального значения на -1.
Это ровно INT_MAX + 1, или (2^31 − 1) + 1 = 2^31, что на единицу больше самого большого представимого знакового значения.
Результат буквально не может быть представлен ни одной 32-битной последовательностью в дополнительном коде.
Никакого переноса через край, который дал бы правильный ответ, нет — просто не существует в этом формате.
Это прямое следствие асимметричного на единицу диапазона.
У каждого отрицательного целого, кроме INT_MIN, есть парное положительное; INT_MIN — единственное исключение. Попытка взять от него противоположное (через унарный - или через деление на -1) требует значения, которое формат выдать не может.
Разные системы обходятся с этим по-разному:
| Система | Поведение |
|---|---|
| Процессоры x86 (напрямую) | Исключение переполнения при делении (программа падает через SIGFPE в Unix) |
| C / C++ | Неопределённое поведение — компилятор может считать, что этого никогда не бывает |
| Java | Определено: Integer.MIN_VALUE / -1 == Integer.MIN_VALUE (заворачивается в себя) |
| Python | Не проблема — int произвольной точности |
| Rust (debug) | Паника с сообщением о переполнении |
| Rust (release) | Заворачивается или паникует в зависимости от настроек сборки |
Всем им приходится делать выбор, потому что математический ответ просто не влезает в битовую последовательность — благополучного пути отступления тут нет.
Арифметика чисел IEEE-754
Арифметика с плавающей точкой — совершенно другой зверь. Вместо чистого модульного мира дополнительного кода IEEE-754 даёт вам сетку представимых значений, у которой шаг удваивается при каждом увеличении порядка: плотно около нуля, разреженно у крайностей. Каждая операция обязана выровнять порядки, выполнить саму математику, заново нормализовать результат и округлить его так, чтобы он влез в мантиссу.
Будем считать, что раскладку IEEE-754 binary64 вы уже знаете — 1 бит знака, 11 бит смещённого порядка, 52 бита мантиссы, неявная ведущая 1. Если это ново, полный разбор есть в статье о том, как Number в JavaScript и float в Python хранят числа. Здесь мы подхватываем там, где та статья заканчивается: формат зафиксирован, а теперь — что происходит, когда вы выполняете арифметику над двумя такими числами.
Короткое замечание о терминологии
Один термин, который встречается ниже, — субнормальное число (иногда его называют денормализованным): очень маленькое число с плавающей точкой, меньшее наименьшего нормального значения . Нормальные числа имеют вид 1.xxx × с неявной ведущей 1; субнормальные эту неявную 1 отбрасывают и кодируются нулевым порядком и ненулевой мантиссой. Это позволяет IEEE-754 представлять значения плавно вниз примерно до , вместо того чтобы прыгать от сразу к нулю — свойство, называемое плавным исчезновением порядка.
Все четыре арифметические операции следуют одному скелету: каждое из трёх полей (знак, порядок, мантисса) обрабатывается отдельно, а затем результат нормализуется (принудительно приводится к виду 1.xxx × 2^e) и округляется так, чтобы влезть в 52 бита мантиссы.
Для умножения и деления операнды уже находятся в виде 1.xxx × 2^e, так что операция идёт напрямую: знаки складываются по XOR, порядки складываются (умножение) или вычитаются (деление), а мантиссы умножаются или делятся. Три шага: операция → нормализация → округление.
Для сложения и вычитания есть дополнительный вступительный шаг — выравнивание — потому что складывать значения можно только в одном масштабе. Мантисса меньшего операнда сдвигается вправо до совпадения порядков, и только потом мантиссы объединяются; знак результата берётся от операнда с большей величиной. Четыре шага: выравнивание → операция → нормализация → округление.
Теперь пройдём каждую операцию подробно, начиная со сложения.
Сложение: выровнять, сложить, нормализовать, округлить
Сложить два числа с плавающей точкой сложнее, чем два целых. Вот пять шагов:
- Сравнить порядки. Взять больший как опорный.
- Выровнять мантиссы. Сдвинуть мантиссу меньшего операнда вправо так, чтобы обе мантиссы представляли значения при одном порядке. При этом биты могут уйти за правый край (они становятся защитным, округляющим и «липким» битами, которые используются для корректного округления).
- Сложить выровненные мантиссы. Обычное двоичное сложение обеих (включая неявные ведущие
1). - Заново нормализовать. Если сумма перелилась в следующий порядок (например,
1.1 + 1.1 = 11.0, что требует1.10 × 2^1), сдвинуть мантиссу вправо и увеличить порядок. Если сумма меньше нормализованного вида (взаимное уничтожение при вычитании), сдвинуть влево и уменьшить порядок. - Округлить. У заново нормализованной мантиссы обычно больше бит, чем позволяет 52-битное поле. Округлить до ближайшего представимого значения по правилу IEEE-754 по умолчанию — к ближайшему, ничьи к чётному.
Как применяются все эти шаги, можно увидеть, если подробно проследить знаменитый случай, когда 0.1 + 0.2 даёт 0.30000000000000004 вместо 0.3. И 0.1, и 0.2 — бесконечные периодические двоичные дроби (так же как 1/3 — это 0.333… в десятичной: ), поэтому каждое при сохранении округляется до 52 бит мантиссы, а затем сложение округляет ещё раз. Три округления, ошибки которых не гасят друг друга. Подробный побитовый разбор есть в соседней статье.
По этому примеру видно, что сложение с плавающей точкой не ассоциативно и не точно. Три округления дают ответ, отличный от одного округления того же математического результата. Именно поэтому (a + b) + c и a + (b + c) могут давать разные результаты в плавающей точке.
Вычитание: ловушка катастрофического сокращения
Вычитание с плавающей точкой следует тому же танцу «выровнять — сложить — нормализовать — округлить», что и сложение, но с одной дополнительной заботой: катастрофическим сокращением.
Когда вычитаются два почти равных числа, ведущие биты взаимно уничтожаются. Шаг повторной нормализации затем сдвигает влево на много позиций, «подтягивая» младшие биты, которые изначально были наименее значимой (и потому наименее точной) частью операндов.
Пример в binary64: два числа double, различающиеся только последним битом мантиссы:
a = 1.0000…0001 × 2⁰(мантисса: 51 ноль, затем 1; равно1 + 2⁻⁵²)b = 1.0000…0000 × 2⁰(мантисса: все нули; равно1.0)
a − b = 0.0000…0001 × 2⁰, что заново нормализуется в 1.0 × 2⁻⁵². 52 ведущих бита мантиссы взаимно уничтожились, а самый младший бит — уже находившийся на пределе точности — стал всем результатом. Если само a было округлённым выходом какого-то предшествующего вычисления, то этот младший бит — в основном шум округления, а вычитание только что повысило его до самой значимой позиции.
Вот почему численный код часто перестраивает формулы так, чтобы не вычитать почти равные величины: сокращение может превратить малую ошибку округления в результат, в котором фактически нет ни одной верной цифры.
Сравнение: порядок бит работает (почти)
Сравнение чисел с плавающей точкой почти так же просто, как сравнение целых: для не-особых значений сравнение двух таких чисел по их битовым последовательностям даёт тот же ответ, что и сравнение их численных значений. Это потому, что IEEE-754 кладёт знак в старший бит, затем порядок, затем мантиссу — так что большая битовая последовательность (прочитанная как знак-величина) соответствует большему числу. Процессоры умеют сравнивать два таких числа одной аппаратной командой.
Но простое правило ломают два особых случая:
NaNне равен ничему, включая самого себя.NaN == NaNдаётfalse. Это сделано намеренно:NaNозначает «нет корректного числа», так что он не может быть равен ничему. Алгоритмам сортировки и хеш-таблицам приходится обрабатыватьNaNособо.+0 == -0, хотя их биты знака различаются. Два нуля численно сравниваются как равные, но полностью взаимозаменяемыми они не являются:1 / +0даёт+∞, а1 / -0даёт-∞.
Это всплывает в неожиданных местах. В JavaScript [NaN].includes(NaN) даёт true (спецификация языка использует там побитовое равенство), а [NaN].indexOf(NaN) даёт -1 (использует ===, который соблюдает правило IEEE-754). А в большинстве языков if (x == x) — это стандартная идиома для «x не является NaN?».
Умножение: проще, чем сложение
Как ни удивительно, умножение с плавающей точкой проще сложения, потому что выравнивать порядки не нужно.
Чтобы вычислить a × b:
- Перемножить мантиссы (включая неявные ведущие
1). Каждая мантисса не меньше1(поскольку неявный ведущий бит равен1) и меньше2(что-либо≥ 2было бы перенормализовано к более высокому порядку). Значит, их произведение не меньше1и меньше4(минимум1 × 1 = 1; максимум чуть меньше2 × 2 = 4), а это значит, что результат либо уже нормализован (1.xxx), либо на один бит велик (1x.xxx). - Сложить истинные порядки. У смещённых порядков сначала нужно убрать смещение, а затем вернуть его: .
- Сложить биты знака по XOR. Положительное × отрицательное = отрицательное, и так далее.
- Заново нормализовать при необходимости. Если произведение мантисс перелилось в
1x.xxx, сдвинуть вправо на 1 и прибавить 1 к порядку. - Округлить мантиссу до 52 бит.
Отсутствие выравнивания означает лишь один источник ошибки округления (шаг 5) плюс та ошибка, что уже была в операндах. Поэтому a × b обычно точнее, чем a + b, когда оба вычисляются на только что округлённых входах.
Как применяются эти шаги, можно увидеть, проследив 0.1 × 0.2, что даёт 0.020000000000000004 вместо 0.02. И 0.1, и 0.2 округляются до одной и той же 52-битной мантиссы (та же бесконечная периодическая двоичная дробь, которую мы видели раньше), различаясь только порядком:
0.1≈1.10011001…10011010× (52 бита)0.2≈1.10011001…10011010× (52 бита)
Прогоняем пять шагов:
- Перемножаем мантиссы. Каждая мантисса декодируется примерно в
1.6в десятичном виде (двоичное1.10011001…10011010— это ближайшее 53-битное приближение8/5 = 1.6). Полное умножение двух 53-битных значений даёт до 106 бит со значением ≈1.6 × 1.6 = 2.56. Поскольку2.56 ≥ 2, результат имеет вид1x.xxxи нужна перенормализация. - Складываем истинные порядки: .
- Складываем знаки по XOR: положительное × положительное = положительное.
- Перенормализуем. Произведение мантисс имеет вид
1x.xxx, так что сдвигаем вправо на 1 (мантисса становится ≈1.28) и увеличиваем порядок с до . - Округляем 106-битное произведение мантисс до 52 бит.
Итоговое сохранённое значение декодируется в точное десятичное 0.0200000000000000004163336342344337026588618755340576171875 — близкое к 0.02, но не равное. Ошибки округления в 0.1 и 0.2 переносятся в произведение, а само произведение округляется ещё раз.
Деление: редко точное
Деление с плавающей точкой повторяет пятишаговую структуру умножения, только операции обращены:
- Разделить мантиссы. Каждая мантисса не меньше
1и меньше2, так что мантисса частного не меньше0.5и меньше2— либо уже нормализована (1.xxx), либо на один бит мала (0.xxx). - Вычесть истинные порядки. Тот же танец «убрать смещение и вернуть», что и при умножении: .
- Сложить биты знака по XOR. Отрицательное / положительное = отрицательное, и так далее.
- Заново нормализовать при необходимости. Если мантисса частного имеет вид
0.xxx, сдвинуть влево на 1 и уменьшить порядок. - Округлить мантиссу до 52 бит.
Большое отличие от умножения: деление редко бывает точным. Два 53-битных значения могут дать частное, для представления которого нужно бесконечно много бит — даже когда оба операнда простые. Например, 1.0 / 3.0 — это двоичный аналог 1/3 = 0.333… в десятичной системе: бесконечная периодическая двоичная дробь, которую приходится округлять. Так что шаг 5 округляет почти всегда, даже когда для операндов никакого округления не требовалось.
Современные процессоры реализуют деление с плавающей точкой через те же итерации Ньютона — Рафсона / Голдшмидта, упомянутые выше для целочисленного деления, сводя его к нескольким умножениям, — но деление всё равно обычно самая медленная из четырёх базовых операций с плавающей точкой.
Переполнение и потеря значимости: бесконечность и ноль вместо переноса через край
В отличие от целых в дополнительном коде, числа с плавающей точкой при переполнении не заворачиваются. IEEE-754 резервирует особые битовые последовательности для «слишком больших» и «слишком малых» результатов:
- Переполнение → порядок превысил бы максимальное представимое значение . Результатом становится , закодированный порядком из одних единиц с нулевой мантиссой.
- Потеря значимости → порядок упал бы ниже минимума ( для нормальных). Результатом становится субнормальное число (у мантиссы нет неявной ведущей
1, что позволяет постепенно терять точность) или в конце концов .
В битовой раскладке (знак | 11 бит порядка | 52 бита мантиссы):
+∞ : 0 | 11111111111 | 0000…0000
-∞ : 1 | 11111111111 | 0000…0000
+0 : 0 | 00000000000 | 0000…0000
-0 : 1 | 00000000000 | 0000…0000
subnormal : s | 00000000000 | xxxx…xxxx (ненулевая мантисса; неявной ведущей 1 нет)
NaN : s | 11111111111 | xxxx…xxxx (ненулевая мантисса)Так что 1e300 × 1e300 в типе double даёт Infinity, а не какую-то завернувшуюся битовую последовательность. А 1e-300 × 1e-300 уходит через субнормальные числа к нулю.
Битовые последовательности для особых значений живут на крайностях диапазона порядка — именно поэтому для поля порядка изначально и был выбран смещённый двоичный код: он помещает нулевую и единичную последовательности порядка на границы, где резервирование естественно.
Деление на ноль: Infinity, а не исключение
Целочисленное деление на ноль в большинстве языков вызывает ловушку или исключение. Деление с плавающей точкой на ноль — нет: оно даёт ±Infinity:
> 1 / 0
Infinity
> -1 / 0
-InfinityPython — исключение (он выбрасывает ZeroDivisionError на уровне языка), но нижележащая арифметика IEEE-754 выдаёт Infinity; Python просто перехватывает его. В JavaScript вы получаете поведение в сыром виде.
0 / 0 — другое дело: оно даёт NaN, особое значение «не число», которое распространяется по дальнейшей арифметике и не равно ничему, включая самого себя.
Чем различаются две системы
Когда обе системы разложены, контрасты бросаются в глаза. Целочисленная арифметика и арифметика с плавающей точкой почти ничего не разделяют на уровне бит, и эти различия имеют практическое значение:
| Аспект | Целые в дополнительном коде | Числа IEEE-754 |
|---|---|---|
| Шаг между значениями | Однородный — каждое целое отстоит на 1 | Логарифмический — плотность наибольшая около нуля, разрежённость у крайностей |
| Сложение | Точное, коммутативное, ассоциативное | С округлением; ассоциативное лишь по случайности |
| Умножение | Точное в пределах диапазона, переполнение заворачивается | Округляется на каждой операции |
| Переполнение | Заворачивается по модулю 2^n | Насыщается до ±Infinity |
| Потеря значимости | Не применимо (у целых её нет) | Плавно через субнормальные, затем ±0 |
| Деление на ноль | Исключение / не определено | ±Infinity (или NaN для 0/0) |
| Знаковость | Закодирована в битах (соглашение о старшем бите) | Явный бит знака; существуют и +0, и -0 |
| Равенство | Побитово точное; x == x всегда истинно | В основном побитово точное; NaN != NaN — исключение |
Для большей части прикладного кода различия проявляются как повседневные странности:
- Целые предсказуемы, но узки. Сложение ассоциативно и точно, так что
(a + b) + c == a + (b + c)всегда. Но выйдите за диапазон — и вы завернётесь (или упадёте, в зависимости от языка). - Числа с плавающей точкой широки, но неточны. Вы можете представлять значения от до , но лишь немногие из них точно. Каждая операция округляет, а ошибки округления накапливаются так, что это зависит от порядка операций.
- Смешивать их нужно осторожно. Преобразование большого
int64вfloat64теряет точность после (граница безопасных целых). Преобразование большого числа с плавающей точкой в целое усекает его. В общем случае ни то, ни другое не обратимо.
Под этими повседневными наблюдениями лежат четыре более глубоких вывода, которые стоит удержать:
- Арифметика целых в дополнительном коде модульна. Каждая операция происходит по модулю , и именно поэтому сложение и вычитание используют один сумматор и почему
INT_MAX + 1 = INT_MIN. Асимметричный диапазон (одно лишнее отрицательное значение) даёт вам крайний случайINT_MIN / -1, где у истинного результата вообще нет битовой последовательности. - Арифметика с плавающей точкой — это «выровнять, сложить, нормализовать, округлить». Каждая операция над двумя такими числами включает повторное выравнивание их порядков (для сложения), выполнение математики, повторную нормализацию результата и округление до 52 бит мантиссы. Каждый шаг может внести ошибку, и поэтому результаты в плавающей точке редко бывают побитово точными.
- Переполнение ведёт себя совершенно иначе. Целые заворачиваются; числа с плавающей точкой насыщаются до бесконечности. Это не поверхностный выбор — он отражает принципиально разное строение двух числовых пространств (плоское модульное против логарифмического с зарезервированными концами).
- Смысл несут не биты, а тип. Одни и те же 32 бита могут быть
intилиfloatв зависимости от того, что решит программа. Сложение в дополнительном коде и сложение с плавающей точкой — совершенно разные операции, которые просто используют одни и те же 32 бита памяти.
Знать, в какой арифметической системе вы находитесь (и каковы её странности), обычно важнее, чем знать конкретную кодировку ваших чисел. Когда вычисление даёт неожиданный ответ, вопрос «какая система это обработала?» обычно оказывается самым быстрым путём к ответу.