Как работает двоичная арифметика: целые в дополнительном коде и числа 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 — и этот отброшенный бит есть модульный перенос через край.
Полная картина (-битная арифметика как окружность из позиций) разобрана в разделе о модульной арифметике в статье о кодировке.
Сохранение младших битов даёт циклический переход: максимальное знаковое значение плюс один становится минимальным. Правила языка могут отличаться от аппаратного результата: знаковое переполнение в C/C++ — неопределённое поведение, а беззнаковая арифметика циклическая. INT_MAX и INT_MIN задают диапазон int конкретной реализации, не обязательно 32-битного. Python int и JavaScript BigInt имеют произвольную точность; JavaScript Number использует числа с плавающей точкой.
Сравнение: где одних бит недостаточно
В отличие от сложения, сравнение всё-таки зависит от интерпретации. Битовая последовательность 1111 как беззнаковая равна 15, так что естественно 1111 > 0001 (15 > 1). Однако если те же самые биты подразумеваются как знаковое -1, то правильный ответ — -1 < 1, а побитовое сравнение всё равно даёт 1111 > 0001. Те же биты, противоположные ответы.
На уровне аппаратуры сравнение часто реализовано как вычитание: процессор вычисляет a - b и выставляет флаги (знак, ноль, переполнение, перенос); команды ветвления затем читают эти флаги, чтобы решить, куда переходить. Само вычитание использует тот же механизм a + (-b), что и выше — процессор просто выбрасывает результат и оставляет только флаги. Несколько 4-битных примеров, чтобы увидеть каждый флаг в действии: В примерах используется соглашение x86: при вычитании флаг переноса обозначает заём.
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 бит, старшую половину отбросить | 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
(различны) (идентичны)Поэтому для младших битов произведения не нужны отдельные схемы знакового и беззнакового умножения. Проверки переполнения и правила языков всё же различаются; знаковое переполнение в C/C++ от этого не становится определённым.
Деление: сдвиг и вычитание
Теперь посмотрим на деление. На уровне бит в нём нет ничего экзотического — это просто деление в столбик, применённое к двоичному виду.
Вспомните школьное деление в столбик, скажем 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 к остатку.
Этот алгоритм сдвигов и вычитаний выполняет итераций для -битного делимого. Это число итераций, а не отдельных битовых операций: сравнение и вычитание тоже требуют работы. Аппаратные делители могут использовать более быстрые алгоритмы; задержка зависит от процессора и операндов.
Для знакового деления с усечением к нулю разделите модули, смените знак частного при разных знаках операндов и задайте ненулевому остатку знак делимого. Для деления вниз в Python нужна также описанная выше поправка; одной смены знака остатка недостаточно.
Это работает для любой пары — кроме одного крайнего случая.
Единственное знаковое деление, которое переполняется
При знаковом делении фиксированной разрядности на ненулевое число переполнение вызывает ровно одна пара: минимальное значение, делённое на -1. Для 32-битного знакового типа:
Это ровно 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) | Паника при MIN / -1, даже если проверки переполнения отключены |
Всем им приходится делать выбор, потому что математический ответ просто не влезает в битовую последовательность — благополучного пути отступления тут нет.
Арифметика чисел IEEE-754
Арифметика с плавающей точкой устроена совершенно иначе. Вместо чистого модульного мира дополнительного кода IEEE-754 даёт вам сетку представимых значений, у которой шаг удваивается при каждом увеличении порядка: плотно около нуля, разреженно у крайностей. Каждая операция обязана выровнять порядки, выполнить саму математику, заново нормализовать результат и округлить его так, чтобы он влез в мантиссу.
Будем считать, что раскладку IEEE-754 binary64 вы уже знаете — 1 бит знака, 11 бит смещённого порядка, 52 бита мантиссы, неявная ведущая 1. Если это ново, полный разбор есть в статье о том, как Number в JavaScript и float в Python хранят числа. Здесь мы подхватываем там, где та статья заканчивается: формат зафиксирован, а теперь — что происходит, когда вы выполняете арифметику над двумя такими числами.
Короткое замечание о терминологии
Один термин, который встречается ниже, — субнормальное число (иногда его называют денормализованным): очень маленькое число с плавающей точкой, меньшее наименьшего нормального значения . Нормальные числа имеют вид 1.xxx × с неявной ведущей 1; субнормальные эту неявную 1 отбрасывают и кодируются нулевым порядком и ненулевой мантиссой. Это позволяет IEEE-754 представлять значения плавно вниз примерно до , вместо того чтобы прыгать от сразу к нулю — свойство, называемое постепенным уходом к нулю (gradual underflow).
Далее предполагаются нормальные конечные операнды и округление к ближайшему с выбором чётного при равном расстоянии. Знак, порядок и значащая часть обрабатываются отдельно, затем результат нормализуется и округляется до 53 значащих битов (52 хранимых дробных бита). Ноль, субнормальные числа, бесконечности, NaN и переполнение требуют отдельных случаев.
Для умножения и деления операнды уже находятся в виде 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 бит мантиссы, а затем сложение округляет ещё раз. Три округления, ошибки которых не гасят друг друга. Подробный побитовый разбор есть в соседней статье.
Этот пример показывает, что сложение не всегда точно. Оно также не всегда ассоциативно: в binary64 (1e16 + -1e16) + 1 даёт 1, а 1e16 + (-1e16 + 1) — 0, поскольку при округлении внутренней суммы теряется 1.
Вычитание: ловушка катастрофического сокращения
Вычитание с плавающей точкой следует тем же шагам «выровнять — сложить — нормализовать — округлить», что и сложение, но с одной дополнительной проблемой: катастрофическим сокращением.
Когда вычитаются два почти равных числа, ведущие биты взаимно уничтожаются. Шаг повторной нормализации затем сдвигает влево на много позиций, «подтягивая» младшие биты, которые изначально были наименее значимой (и потому наименее точной) частью операндов.
Пример в binary64: два числа double, различающиеся только последним битом мантиссы:
a = 1.0000…0001 × 2⁰(мантисса: 51 ноль, затем 1; равно1 + 2⁻⁵²)b = 1.0000…0000 × 2⁰(мантисса: все нули; равно1.0)
Здесь a − b = 2⁻⁵² вычисляется точно: само сокращение разрядов не означает ошибку округления. Опасность возникает, если операнды уже содержат ошибки предыдущих вычислений. Тогда у их малой разности может быть большая относительная ошибка.
Вот почему численный код часто перестраивает формулы так, чтобы не вычитать почти равные величины: сокращение может превратить малую ошибку округления в результат, в котором фактически нет ни одной верной цифры.
Сравнение: порядок бит работает (почти)
Для неотрицательных конечных binary64 беззнаковый порядок битовых шаблонов совпадает с числовым. Для отрицательных значений порядок модулей нужно обратить; при разных знаках требуется учитывать знак. Поэтому простое беззнаковое сравнение подходит не для всех чисел с плавающей точкой.
Но простое правило ломают два особых случая:
NaNне равен ничему, включая самого себя.NaN == NaNдаётfalse. Это сделано намеренно:NaNозначает «нет корректного числа», так что он не может быть равен ничему. Алгоритмам сортировки и хеш-таблицам приходится обрабатыватьNaNособо.+0 == -0, хотя их биты знака различаются. Два нуля численно сравниваются как равные, но полностью взаимозаменяемыми они не являются:1 / +0даёт+∞, а1 / -0даёт-∞.
В JavaScript [NaN].includes(NaN) возвращает true благодаря SameValueZero: оно считает NaN равными и приравнивает +0 к -0. Это не побитовое равенство. [NaN].indexOf(NaN) возвращает -1, поскольку использует строгое равенство.
Умножение: проще, чем сложение
Как ни удивительно, умножение с плавающей точкой проще сложения, потому что выравнивать порядки не нужно.
Чтобы вычислить 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 бит.
И сложение, и умножение округляют точный результат один раз к целевому формату. Выравнивание порядков не добавляет отдельного округления, если корректно сохраняется информация guard, round и sticky. Ни одна из операций не является заведомо точнее для произвольных входов.
Как применяются эти шаги, можно увидеть, проследив 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-битное произведение мантисс до 53 значащих битов (52 хранимых и одного неявного ведущего бита).
Итоговое сохранённое значение декодируется в точное десятичное 0.02000000000000000388578058618804789148271083831787109375 — близкое к 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 резервирует особые битовые последовательности для «слишком больших» и «слишком малых» результатов:
- Переполнение: округлённому результату нужен порядок выше максимального —
1023для binary64. При округлении к ближайшему получается±∞; направленные режимы могут вернуть максимальный конечный модуль с нужным знаком. - Очень малые результаты: ниже минимального нормального модуля субнормальные числа сохраняют часть точности вплоть до ; меньшие результаты могут округлиться до нуля. Точный субнормальный результат не обязательно устанавливает флаг потери значимости (underflow).
В битовой раскладке (знак | 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 даёт бесконечность, а 1e-300 * 1e-300 округляется сразу до нуля. Постепенный уход к нулю описывает доступные субнормальные значения, а не последовательность промежуточных результатов каждой операции.
Битовые последовательности для особых значений живут на крайностях диапазона порядка — именно поэтому для поля порядка изначально и был выбран смещённый двоичный код: он помещает поля порядка из одних нулей и одних единиц на границы, где резервирование естественно.
Деление на ноль: Infinity, а не исключение
Целочисленное деление на ноль в большинстве языков вызывает ловушку или исключение. Деление с плавающей точкой на ноль — нет: оно даёт ±Infinity:
> 1 / 0
Infinity
> -1 / 0
-InfinityPython — исключение (он выбрасывает ZeroDivisionError на уровне языка), но нижележащая арифметика IEEE-754 выдаёт Infinity; Python просто перехватывает его. В JavaScript вы получаете поведение в сыром виде.
0 / 0 — другое дело: оно даёт NaN, особое значение «не число», которое распространяется по дальнейшей арифметике и не равно ничему, включая самого себя.
Чем различаются две системы
После разбора обеих систем различия хорошо видны. Целочисленная арифметика и арифметика с плавающей точкой имеют мало общего на уровне бит, и эти различия имеют практическое значение:
| Свойство | Дополнительный код фиксированной разрядности | IEEE-754 binary64 |
|---|---|---|
| Шаг | Один между соседними целыми | Зависит от порядка; постоянен для субнормальных |
| Сложение и умножение | Точные в пределах диапазона; переполнение зависит от языка | Корректно округляются к выбранному формату |
| Переполнение | Циклический переход, ошибка или неопределённое поведение | Бесконечность или максимальное конечное значение в зависимости от округления |
| Деление на ноль | Обработка ошибки зависит от языка | Для конечного ненулевого делимого по умолчанию бесконечность без ловушки; 0/0 даёт NaN |
| Ноль | Один код | +0 и -0, численно равные |
Для большей части прикладного кода различия проявляются как повседневные странности:
- Целочисленное сложение точно, если промежуточные результаты помещаются в диапазон. Поведение при переполнении определяется языком, а не только кодировкой.
- Арифметика с плавающей точкой имеет широкий диапазон, но конечную точность. Из-за округления порядок операций может влиять на результат.
- Преобразования между целыми и float могут терять информацию. Выше binary64 представляет не все целые; обратное преобразование зависит также от языка и диапазона целевого типа.
Знать, в какой арифметической системе вы находитесь (и каковы её странности), обычно важнее, чем знать конкретную кодировку ваших чисел. Когда вычисление даёт неожиданный ответ, вопрос «какая система это обработала?» обычно оказывается самым быстрым путём к ответу.