В этой статье мы поговорим о дробных числах (float) и их представлении в компьютере, в частности, о том, как их описывает стандарт IEEE 754, принятый в 1985 году. Если вас интересует представление целых чисел, об этом можно почитать в предыдущей статье.
Это запись по следам видеоурока, который можно найти на YouTube:
Эта запись также доступна в канале Telegram “DEV: Рубиновые тона”, а обсудить же эту тему можно в нашем чате Telegram.
Дробные десятичные числа и их представления
Чтобы было проще, начнём с обычного десятичного числа 35.42
, у которого, как можно видеть, имеется дробная часть. Как ещё можно записать это число? Ну, к примеру, вот так:
3 * (10 ** 1) + 5 * (10 ** 0) + 4 * (10 ** -1) + 2 * (10 ** -2)
То есть тут повторяется ситуация с порядковыми номерами, которую мы видели ранее при обсуждении целых чисел. Каждая цифра здесь привносит определённое значение в зависимости от своего порядкового номера. Так, у тройки порядковый номер будет 1, а у пятёрки — 0. При этом цифрам, стоящим после точки, мы просто присваиваем отрицательные индексы.
В принципе, это же число можно представить как 35 + (42 / 100)
, суть та же.
Дробные двоичные числа и их представления
Ясное дело, что принцип, описанный выше, можно применить и двоичным числам. К примеру, если взять двоичное 101.11
, то у нас выйдет:
1 * (2 ** 2) + 0 * (2 ** 1) + 1 * (2 ** 0) + 1 * (2 ** -1) + 1 * (2 ** -2)
Или, проще говоря:
4 + 0 + 1 + (1/2) + (1/4)
То есть мы видим, что после точки у нас появляются дроби со степенями двойки: 1/2
, 1/4
, 1/8
, и так далее.
С помощью этого метода мы можем легко представить десятичную дробь 3/16
(или 0.1875
), это будет 0.0011
в двоичной. И хотя на этом подходе можно было бы и остановиться, он не слишком удобен, особенно для очень больших чисел. Именно поэтому был придуман стандарт IEEE 754.
IEEE 754
Итак, стандарт IEEE 754 представляет дробные числа в виде формулы:
((-1) ** s) * M * (2 ** E)
Здесь три параметра, и в компьютере каждый хранится в своём поле:
s
— это информация о знаке. Данный параметр равен либо 0, либо 1, поэтому соответствующее поле занимает всегда 1 бит.M
— это мантисса, дробное число, обычно меньше 1. Его представляет полеfrac
, занимает оноn
бит и содержит последовательностьf(n-1), ..., f(1), f(0)
.E
— это экспонента, которая может быть как положительной, так и отрицательной. Её представляет полеexp
длинойk
бит с последовательностьюe(k-1), ..., e(1), e(0)
.
Итак, согласно стандарту, дробные числа состоят аж из трёх полей сразу, и обычно имеют либо одинарную (single, размерность 32 бита), либо двойную (double, размерность 64 бита) точность. К примеру, для одинарной точности s
занимает один бит номер 31, exp
— с 23 по 30 биты, остальное (с 0 по 22) отводится под мантиссу.
Однако дальше становится немного сложно, так как стандарт описывает три возможных случая, связанных со значениями мантиссы и экспоненты.
Случай 1: Нормированные значения
Сначала поговорим о самом типичном сценарии, который описывает нормированные значения. Это случай, когда экспонента не состоит целиком из нулей или целиком из единиц. Говорят, что экспонента хранится в смещённой (biased) форме, а само её значение считается как:
E = e - B
e
— число без знака с последовательностьюe(k-1), ..., e(1), e(0)
.B
— это значение bias, которое равно2 ** (k - 1) - 1
. Так, для одинарной точности оно составляет 127, потому что в этом случае длина поляexp
составляет 8 бит.
Следовательно, для одинарной точности финальное значение E
будет лежать в пределах от -126 (так как e
— число без знака, и оно точно больше нуля в данном случае) до 127.
Поле frac
в этом случае описывает дробную часть, то есть его значение f
в десятичном виде лежит от 0 (включительно) до 1 (не включительно). Это довольно важно, процесс формирования f увидим в примере ниже.
Финальное значение мантиссы считается как M = 1 + f
.
Случай 2: Денормированные значения
Следующий случай — денормированные значения, это когда в поле exp
содержатся все нули.
Тогда экспонента считается по формуле E = 1 - B
(откуда берётся B
мы уже знаем).
Мантисса будет просто равна полю frac
, то есть M = f
.
Такие денормированные значения, в частности, используются, чтобы представить значение 0
. Но вы можете спросить: “А чего бы нам для нуля не использовать первый случай”? Ну потому, что там мантисса считается как M = 1 + f
, то есть в любом случае M >= 1
, ноль таким образом никак не представишь.
Кстати, в этом же случае получается интересный момент: у нас может быть как +0
, так и -0
. Почему? Потому что даже если биты в exp
и frac
занулены, s
всё равно может иметь значение как 0, так и 1.
На самом деле, этот случай используется ещё и тогда, когда нужно представить числа, очень близкие к нулю, как мы увидим в примере ниже.
Случай 3: Специальные значения
Последний третий случай описывает специальные значения: это когда в поле exp
записаны все единицы. Правда, этот случай содержит два других возможных сценария.
Если в frac
записаны одни нули, то таким образом мы представляем бесконечность (плюс или минус бесконечность, зависит от знака s
). Ну, к примеру, если мы поделили на ноль.
Если в frac
указано что-то иное (не все нули), то это значение называется “not a number” (NaN
). Оно может вылезти, если результат невозможно представить реальными числами (настоящие джедаи помнят про мнимые числа, но это не тот случай, про них данный стандарт ничего не говорит), либо если происходит что-то странное в духе “бесконечность минус бесконечность”.
Примеры: Представление чисел с плавающей точкой
В качестве примера давайте возьмём 8-битный формат (можно было бы взять и одинарную точность, но там просто несколько сложнее было бы считать), где на exp
отводится k=4
бит, а на frac
даётся n=3
бит. Понятно, что на знак в любом случае занимает 1 бит. В этом случае значение bias считается как B = 2 ** (4-1) - 1 = 7
.
Нуль
Как мы представим в этом случае ноль? Ну, очевидно вот так (биты разграничены по соответствующим полям):
0 0000 000
Здесь e=0
, E = 1 - 7 = -6
.
f
можно записать как 0 / 8
(так как в поле frac
у нас все нули). Следовательно, мантисса M = f = 0 / 8
.
Тогда наша формула, представленная выше, преобразуется в такую:
((-1) ** 0) * (0/8) * (2 ** -6) = 0
Положительная дробь
Классно. Теперь попробуем представить число 7/8
, то есть 0.875
. Его представление:
0 0110 110
e = 0110 = 6
, тогда E = 6 - 7 = -1
.
Теперь f
. У нас под это поле отводится три бита, то есть 2 ** 3 = 8
(двойка тут потому, что система счисления двоичная), а само значение frac = 110
, то есть 6 в десятичной. Выходит, что f = 6/8
, а мантисса считается как M = 14 / 8
.
Подставляем в формулу:
((-1) ** 0) * (14 / 8) * (2 ** -1) = 0.875
Самое большое из денормированных чисел
Попробуем ещё взять вот такое число, это в нашем случае самое большое из денормированных:
0 0000 111
Выходит, что e = 0
, E = -6
. При этом f = M = 7/8
.
Подставим в формулу:
((-1) ** 0) * (7/8) * (2 ** -6) ~ 0.013671875
Самое маленькое из нормированных чисел
А если попробовать представить самое маленькое из нормированных чисел? Оно записывается так:
0 0001 000
Тут e = 1
, E = -6
, f = 0 / 8
, M = 8 / 8
.
Подставляем в формулу:
((-1) ** 0) * (8/8) * (2 ** -6) ~ 0.015625
Самое маленькое положительное число
Аналогично, самое маленькое положительное число, которое мы можем представить (грубо говоря, самое близкое к нулю):
0 0000 001
Тут e = 0
, E = -6
, f = M = 1/8
.
Подставляем в формулу:
((-1) ** 0) * (1/8) * (2 ** -6) = 0.001953125
Вот это как раз и есть случай с денормированными числами, когда мы представляем очень близкое к нулю значение.
Распределение чисел
Кстати, тут выходит один интересный момент. Смотрите, что получилось в наших примерах:
0 0000 000 = 0
0 0000 001 = 0.001953125
0 0000 111 = 0.013671875
0 0001 000 = 0.015625
0 0110 110 = 0.875
Я расположил эти числа по возрастанию от самого маленького десятичного к самому большому. Но при этом можно видеть, что их двоичные аналоги тоже стоят по возрастанию! Это вовсе не случайно: стандарт создавался с учётом того, что программистам наверняка потребуется сортировать такие числа по тем же принципам, что и для целых чисел (хотя тут есть небольшая проблема, если в первом бите появляется 1, то есть число отрицательное).
Собственно говоря, из примеров выше мы видим, что представить “любое” число мы с помощью такого стандарта не можем. К примеру, у нас идёт “перескок” от 0.013671875
сразу к 0.015625
, и сделать с этим особенно ничего не получится. Да, можно использовать не 8 бит, а 32 или даже 64 (двойная точность), но, как вы понимаете, всё равно покрыть все возможные случаи никак не выйдет. Поэтому в том же Rust, да и в других языках, есть понятие “эпсилон”, то есть определённая погрешность, которую стоит учитывать.