В этой статье мы поговорим о дробных числах (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, и так далее.
И хотя на этом подходе можно было бы и остановиться, хранить таким образом числа не всегда удобно, особенно когда приходится работать как с очень большими, так и с очень маленькими значениями. Поэтому в компьютерах используют представление с плавающей точкой, напоминающее научную запись числа, только с основанием 2 вместо 10. А чтобы разные компьютеры не устраивали собственный цирк с такими числами, в 1985 году был принят стандарт IEEE 754, определивший общие правила их представления и обработки.
IEEE 754
Итак, стандарт IEEE 754 представляет дробные числа в виде формулы:
((-1) ** s) * M * (2 ** E)
Здесь три параметра, и в компьютере каждый хранится в своём поле:
s— это информация о знаке. Данный параметр равен либо 0, либо 1, поэтому соответствующее поле занимает всегда 1 бит.M— это мантисса (значащая часть числа). Её значение вычисляется на основе поляfrac, которое содержит дробную часть и занимает n бит. Для нормализованных чиселM = 1 + f, а для денормированныхM = f.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). Например, положительная бесконечность может получиться при делении 1.0 на 0.0. А вот если поделить 0.0 на 0.0, получится уже NaN — такая вот арифметическая загогулина.
Если в 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
Я расположил эти числа по возрастанию от самого маленького десятичного к самому большому. Но при этом можно видеть, что их двоичные аналоги тоже стоят по возрастанию! Это вовсе не случайно: у положительных конечных чисел в таком представлении порядок битовых комбинаций совпадает с порядком самих чисел, если рассматривать эти комбинации как беззнаковые целые. Но не спешите радоваться: для отрицательных чисел такой трюк уже не сработает напрямую. А если вспомнить про NaN и два разных нуля, веселья станет ещё больше.
Собственно говоря, из примеров выше мы видим, что представить любое число с помощью такого формата не получится. К примеру, у нас идёт «перескок» от 0.013671875 сразу к 0.015625, и сделать с этим особенно ничего не получится. Да, можно использовать не 8 бит, а 32 или даже 64 (двойная точность), но, как вы понимаете, всё равно покрыть все возможные случаи никак не выйдет.
Причём расстояние между соседними представимыми числами не постоянно: чем больше число по модулю, тем обычно больше этот промежуток. Поэтому при вычислениях с плавающей точкой приходится учитывать возможные погрешности округления. В Rust, например, есть константы f32::EPSILON и f64::EPSILON, которые показывают расстояние между 1.0 и следующим большим представимым числом соответствующего типа. Только не вздумайте считать их универсальной допустимой погрешностью для любых вычислений — это совсем другая история.