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

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

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

Откройте консоль браузера и сложите 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⁻³¹. Фиксированная точка — одинаковый шаг между соседними числами по всей шкале. Плавающая точка — шаг зависит от масштаба: возле нуля числа расположены плотно, далеко от нуля — разреженно. Между 1 и 2 представимых значений ровно столько же, сколько между 1024 и 2048 или между любыми двумя соседними степенями двойки, — но чем дальше такой промежуток от нуля, тем шире раскиданы числа внутри него. Точность остаётся постоянной в относительных терминах, а абсолютный шаг между соседними числами растёт вместе с их масштабом.

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

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

К 1980-м годам разные производители процессоров отвечали на эти вопросы по-своему: одна и та же программа давала разные результаты на разных машинах. В 1985 году IEEE — профессиональная организация инженеров, выпускающая отраслевые стандарты, — принял стандарт 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.10000000000000000555…   (чуть больше, чем 0.1)

То же самое с 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³⁰⁸

Округление при каждой операции мы только что видели на 0.1 + 0.2: одна прибавка ничтожна. Но в реальном расчёте операций не две, а миллионы — и тогда возникает вопрос, насколько накопится ошибка и можно ли её удержать. С него начинается следующая заметка — вместе с тем, что формат делает в крайних точках: при делении на ноль, корне из отрицательного числа, выходе за диапазон.

Sources


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