Числа с плавающей точкой

Предпосылки: Двоичная система и байты, Целые числа.

Побитовые операции | Крайние случаи чисел с плавающей точкой

Откройте консоль браузера и сложите 0.1 + 0.2. Ответ — 0.30000000000000004. В школе такое сложение дало бы 0.3 без дополнительных цифр в конце. Калькулятор в телефоне тоже показывает 0.3. А процессор, который умеет вычислять траектории спутников и обучать нейросети, ошибается на простейшей сумме из двух дробей.

Привычная модель «компьютер просто складывает, как я на бумаге» не объясняет эту прибавку. Чтобы понять, откуда берутся лишние нули и четвёрка в конце, нужно разобрать, как дробные числа вообще помещаются в фиксированное количество бит и какой компромисс лежит в основе этого представления.

Двоичные дроби

Позиционная запись работает и после точки. В десятичной системе разряды после точки — это 10⁻¹ = 0.1, 10⁻² = 0.01, 10⁻³ = 0.001. В двоичной — то же самое, но степени двойки:

ПозицияВесДесятичное значение
-12⁻¹0.5
-22⁻²0.25
-32⁻³0.125
-42⁻⁴0.0625

Как перевести число с дробной частью — например, 3.625 — в двоичную? Целую и дробную часть переводим отдельно.

Целая часть переводится так же, как в двоичной системе: 3 = 2 + 1 = 11₂.

Дробную часть переводим последовательным умножением на 2:

0.625 × 2 = 1.25  --> 1   (записываем 1, продолжаем с 0.25)
0.25  × 2 = 0.5   --> 0
0.5   × 2 = 1.0   --> 1   (дробная часть = 0, готово)

Дробная часть: 0.625₁₀ = 0.101₂. Склеиваем целую и дробную: 3.625₁₀ = 11.101₂.

Проверка — складываем веса позиций, где стоит единица:

11.101₂ = 1×2 + 1×1 + 1×0.5 + 0×0.25 + 1×0.125 = 2 + 1 + 0.5 + 0.125 = 3.625₁₀ ✓

Не все десятичные дроби переводятся ровно. 0.1₁₀ в двоичной даёт бесконечную периодическую дробь — именно это станет главным источником неприятностей, когда дойдём до суммы 0.1 + 0.2.

На бумаге можно написать 11.101₂ — три бита целой части, точка, три бита дробной. Но в памяти компьютера нет символа «точка». Есть только биты — фиксированное количество нулей и единиц. Если записать 11101 подряд, процессор не знает, где заканчивается целая часть и начинается дробная. Нужно соглашение: какие биты отвечают за целую часть, а какие — за дробную.

Фиксированная точка: простое решение и его границы

Первая идея: разделить биты пополам. Старшие биты — целая часть, младшие — дробная. Например, в 16-битном числе 8 бит хранят целую часть, 8 бит — дробную. Такой формат называется фиксированная точка (fixed point), потому что граница между целой и дробной частью всегда на одном месте.

  Формат 8.8 (8 бит целой части, 8 бит дробной):
 
  [IIIIIIII.FFFFFFFF]
   целая     дробная
   часть     часть
 
  Пример: 00000011.10000000 = 3 + 0.5 = 3.5

Дробная часть из 8 бит даёт точность до 1/256 ≈ 0.0039 — минимальный шаг между соседними значениями.

Арифметика с фиксированной точкой простая: сложение и вычитание — как у целых чисел (точка на одном месте, складываем бит за битом). Умножение чуть сложнее, но предсказуемо. Каждое значение представлено точно в пределах разрешения формата.

Но диапазон тесный. Формат 8.8 — беззнаковое целое от 0 до 255, дробная часть от 0 до 255/256. Максимальное число — 255.996. В 16.16 (32 бита) диапазон вырастет до 0–65535.9999… Для повседневной арифметики достаточно, но мир полон чисел на разных концах шкалы: масса электрона — 9.109 × 10⁻³¹ кг, число Авогадро — 6.022 × 10²³. Ни один формат с фиксированной точкой не вместит оба значения: для огромных чисел нужно много бит целой части, для крошечных — много бит дробной. Каждый раз, когда мы добавляем точность дробной части, мы отбираем диапазон у целой, и наоборот.

Нужен формат, в котором одни и те же 32 бита могут описать и 10²³, и 10⁻³¹. Фиксированная точка этого не позволяет — точка стоит на месте, и биты жёстко распределены между целой и дробной частями.

Научная запись: точка, которая плавает

Физики давно решили эту проблему в десятичной системе. Вместо того чтобы записывать все нули, они пишут:

6 022 000 000 000 000 000 000 00  -->  6.022 × 10^23
0.000 000 000 000 000 000 000 000 000 000 9109  -->  9.109 × 10^(-31)

Формат один и тот же: знак, значащие цифры и показатель степени, который указывает, куда сдвинуть точку. Точка «плавает» — перемещается в зависимости от показателя. Значащих цифр мало (4 в примерах выше), но показатель может быть и +23, и −31.

В двоичной системе работает та же идея: любое ненулевое число можно представить в виде ±1.дробная_часть × 2^показатель. Например:

  0.15625 в десятичной
= 0.00101 в двоичной
= 1.01 × 2^(-3)   (сдвинули точку на 3 позиции вправо)

Вместо фиксированного разделения «столько-то бит на целую часть, столько-то на дробную» мы выделяем биты на показатель степени. Показатель подвигает точку туда, где она нужна. Это и есть числа с плавающей точкой (floating point) — точка не зафиксирована, а плавает благодаря показателю.

Компромисс: жертвуем точностью (количеством значащих цифр), но взамен получаем огромный диапазон. Четыре значащие цифры покрывают и 10²³, и 10⁻³¹. Фиксированная точка — одинаковый шаг между соседними числами по всей шкале. Плавающая точка — шаг зависит от масштаба: возле нуля числа расположены плотно, далеко от нуля — разреженно. Конкретный пример: в float32 между 1.0 и 2.0 лежит 2²³ = 8 388 608 представимых чисел — шаг между соседними составляет около 1.2 × 10⁻⁷. Между 1 000 000 и 2 000 000 представимых чисел столько же, но шаг уже примерно 0.12. Между 10³⁰ и 2 × 10³⁰ — тоже 8 388 608, но шаг порядка 10²³. Точность всегда одинакова в относительных терминах (~7 значащих цифр), но абсолютная погрешность растёт вместе с масштабом числа.

IEEE 754: стандартный формат

Идея ясна: знак, значащие цифры, показатель. Но как именно упаковать их в 32 или 64 бита? Сколько бит выделить на показатель, а сколько на значащие цифры? Как кодировать отрицательный показатель?

К 1980-м годам разные производители процессоров отвечали на эти вопросы по-своему: одна и та же программа давала разные результаты на разных машинах. В 1985 году IEEE (Institute of Electrical and Electronics Engineers — Институт инженеров по электротехнике и электронике) принял стандарт IEEE 754, который зафиксировал единый формат. Сегодня его поддерживают практически все процессоры.

Число состоит из трёх полей: знак (sign), показатель степени (exponent, экспонента) и дробная часть значащего числа (mantissa, мантисса):

float32 (32 бита, одинарная точность):
 
  [S|EEEEEEEE|MMMMMMMMMMMMMMMMMMMMMMM]
   1    8                23              = 32 бита
 
float64 (64 бита, двойная точность):
 
  [S|EEEEEEEEEEE|MMMM...........MMMM]
   1      11              52             = 64 бита
  • S (1 бит) — знак: 0 = положительное, 1 = отрицательное.
  • E (8 или 11 бит) — показатель степени (экспонента). Определяет масштаб числа.
  • M (23 или 52 бита) — мантисса. Определяет значащие цифры.

Почему именно такое распределение? Больше бит экспоненте — шире диапазон, но меньше точность. Больше бит мантиссе — выше точность, но уже диапазон. 8 бит экспоненты в float32 покрывают порядки от 10⁻³⁸ до 10³⁸ — достаточно для большинства физических величин. 23 бита мантиссы дают около 7 десятичных цифр точности. Для float64 баланс смещён: 11 бит экспоненты (до 10³⁰⁸) и 52 бита мантиссы (~15 десятичных цифр).

Смещение экспоненты

Три поля определены, но остаётся вопрос: показатель степени может быть отрицательным. Число 0.15625 = 1.01 × 2⁻³, здесь экспонента = −3. Как хранить отрицательную экспоненту в поле из 8 бит?

Первая идея — дополнительный код, как для целых чисел. Но посмотрим, что получится. Допустим, у нас два числа:

Число A: экспонента +5  -> в доп. коде: 00000101
Число B: экспонента -3  -> в доп. коде: 11111101

Число A больше числа B (2⁵ > 2⁻³). Но если сравнить хранимые экспоненты как обычные беззнаковые числа — 11111101 (253) больше 00000101 (5). Получается, меньшее число «выглядит» большим. Чтобы сравнивать правильно, процессору пришлось бы сначала распознавать знак экспоненты — лишняя работа.

Решение проще: смещение (bias). К настоящей экспоненте прибавляется фиксированная величина, чтобы хранимое значение всегда было неотрицательным.

Для float32 смещение = 127. Хранимая экспонента = настоящая + 127:

  • настоящая экспонента 0 хранится как 127 (01111111)
  • настоящая экспонента +10 хранится как 137 (10001001)
  • настоящая экспонента −3 хранится как 124 (01111100)

Для float64 смещение = 1023, по той же логике: 2¹¹⁻¹ − 1 = 1023.

Теперь большая хранимая экспонента всегда означает большее число — побитовое сравнение работает правильно для чисел одного знака. Это не случайное удобство. Конструкторы IEEE 754 специально расположили поля в порядке «знак, экспонента, мантисса», чтобы для положительных чисел побитовое сравнение как беззнаковых целых давало правильный порядок. Одна схема сравнения обслуживает и целые, и дробные числа — это упрощает аппаратуру.

Скрытый бит: бесплатная точность

Экспонента кодируется через смещение, знак занимает один бит. Остаётся мантисса — и здесь есть возможность выжать дополнительную точность без единого лишнего бита.

В двоичной научной записи любое нормализованное ненулевое число имеет вид 1.что-то × 2^N. Цифра перед точкой всегда 1 — других ненулевых цифр в двоичной системе нет. Раз эта единица всегда одинаковая, зачем тратить на неё бит?

IEEE 754 не хранит эту единицу. Мантисса записывает только цифры после точки. При чтении числа процессор автоматически подставляет 1 перед точкой. Это называется скрытый бит (hidden bit, implicit bit).

23 бита мантиссы в float32 хранят 23 цифры после точки, но с учётом скрытой единицы значащая часть состоит из 24 двоичных цифр. Один бит точности — бесплатно. По аналогии: если все номера домов на улице начинаются с «1», печатать эту единицу на каждом конверте расточительно — достаточно запомнить правило.

Для float64 скрытый бит превращает 52 хранимых бита в 53 значащих.

Оговорка: слово «нормализованное» сказано не зря. У формата есть ещё один режим для чисел, слишком маленьких для 1.что-то × 2^N, и для них правило скрытого бита не работает. Этот режим разбирается в крайних случаях.

Пошаговый пример: кодируем 0.15625

Переведём число 0.15625 в формат float32.

Шаг 1. Перевод в двоичную систему. Последовательно умножаем дробную часть на 2:

0.15625 * 2 = 0.3125  --> 0
0.3125  * 2 = 0.625   --> 0
0.625   * 2 = 1.25    --> 1   (отбрасываем целую часть)
0.25    * 2 = 0.5     --> 0
0.5     * 2 = 1.0     --> 1   (отбрасываем целую часть)

Результат: 0.15625 = 0.00101 в двоичной.

Шаг 2. Нормализация. Сдвигаем точку так, чтобы перед ней стояла единица:

0.00101 = 1.01 * 2^(-3)

Шаг 3. Заполняем поля:

  • Знак = 0 (число положительное)
  • Настоящая экспонента = −3, хранимая = −3 + 127 = 124 = 01111100
  • Мантисса = 01 (цифры после точки в 1.01; скрытая единица не хранится), дополненная нулями до 23 бит: 01000000000000000000000

Результат:

0  01111100  01000000000000000000000
S  экспон.   мантисса

Проверка: знак 0 (плюс), экспонента 124 − 127 = −3, мантисса 1.01 (скрытый бит + 01). Число = +1.01 × 2⁻³ = +0.00101 = 0.15625. Верно.

Попробуем ещё одно число — отрицательное: −6.5.

6.5 = 110.1 в двоичной = 1.101 * 2^2
 
Знак       = 1 (отрицательное)
Экспонента = 2 + 127 = 129 = 10000001
Мантисса   = 101 (после точки в 1.101), дополняем нулями до 23 бит:
             10100000000000000000000
 
Результат: 1  10000001  10100000000000000000000

Обратная операция — декодирование — работает в обратном порядке: извлекаем поля, вычитаем смещение из экспоненты, подставляем скрытый бит, восстанавливаем число.

Почему 0.1 + 0.2 не равно 0.3

Теперь можно вернуться к консольной аномалии из первого абзаца. Разберём её по шагам.

Проблема: бесконечные двоичные дроби. Число 1/3 в десятичной системе — бесконечная дробь: 0.3333… Её невозможно записать точно конечным количеством десятичных цифр. Аналогичная ситуация возникает в двоичной системе, но с другими числами.

Переведём 0.1 в двоичную:

0.1 * 2 = 0.2  --> 0
0.2 * 2 = 0.4  --> 0
0.4 * 2 = 0.8  --> 0
0.8 * 2 = 1.6  --> 1
0.6 * 2 = 1.2  --> 1
0.2 * 2 = 0.4  --> 0   <-- цикл начался заново
0.4 * 2 = 0.8  --> 0
...

0.1 в двоичной = 0.0001100110011001100110011… — бесконечная периодическая дробь с периодом 0011. В float64 мантисса — 52 бита. Бесконечная дробь обрезается, и сохранённое значение чуть больше, чем 0.1:

сохранённое 0.1 = 0.1000000000000000055511151231257827021181583404541015625

То же самое с 0.2 — тоже бесконечная двоичная дробь, тоже обрезается с небольшим избытком.

Сложение. Процессор складывает два приближённых значения. Результат: 0.30000000000000004 (в float64). Это не 0.3.

А что со значением 0.3? 0.3 — тоже бесконечная двоичная дробь, и тоже округляется при сохранении. Но округляется иначе, чем сумма 0.1 + 0.2. Поэтому stored(0.1) + stored(0.2) ≠ stored(0.3), хотя математически 0.1 + 0.2 = 0.3.

Это не ошибка реализации — это фундаментальное следствие того, что конечное количество бит не может точно представить все десятичные дроби. В десятичной системе та же проблема с 1/3: 0.333 + 0.333 + 0.333 = 0.999, а не 1.000.

Какие десятичные дроби представляются в двоичной точно? Только те, чей знаменатель — степень двойки: 0.5 (= 1/2), 0.25 (= 1/4), 0.125 (= 1/8), 0.375 (= 3/8). Числа вроде 0.1 (= 1/10), 0.2 (= 1/5), 0.3 (= 3/10) — нет, потому что 10 = 2 × 5, а множитель 5 не раскладывается на двойки. Любая дробь со знаменателем, содержащим множитель 5, даёт бесконечную периодическую двоичную дробь.

Что это меняет на практике

Лишняя четвёрка в 0.30000000000000004 — не косметика. Из неё следуют несколько правил, без которых float подводит в обычных задачах.

Сравнение. a == b для чисел с плавающей точкой почти всегда ненадёжно: две разные цепочки вычислений могут прийти к математически одному и тому же числу, но округлить его по-разному. Вместо точного равенства проверяют, что |a − b| достаточно мало относительно масштаба самих чисел.

Деньги. Сумма 0.10 + 0.20 должна быть ровно 0.30, без отклонений. Float этого не гарантирует, а при миллионах транзакций даже копеечные расхождения копятся в реальные деньги. Стандартные обходы — хранить суммы в наименьших единицах как целые числа (1099 вместо 10.99) или использовать десятичные типы базы данных (decimal, numeric), которые считают в основании 10 и точно представляют 0.10 и 0.30.

Точность. Мантисса float32 — 23 + 1 = 24 двоичные цифры, это около 7 десятичных. Начиная с 8-й десятичной цифры float32 теряет точность. float64 — 53 двоичные цифры, около 15–16 десятичных.

ФорматМантисса (бит)Десятичных цифрМакс. экспонентаДиапазон
float3223 + 1~7+127≈ ±3.4 × 10³⁸
float6452 + 1~15–16+1023≈ ±1.8 × 10³⁰⁸

У этих правил есть продолжение: что происходит при делении на ноль, как ведёт себя квадратный корень из отрицательного числа, почему сумма миллиона одинаковых чисел не равна их произведению на количество — и как защищаться от накопления ошибок. Этим занимается следующая заметка.

Sources


Побитовые операции | Крайние случаи чисел с плавающей точкой