Как в компьютере представлены дробные числа?

#cs #cpu

В этой статье мы поговорим о дробных числах (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, да и в других языках, есть понятие “эпсилон”, то есть определённая погрешность, которую стоит учитывать.