Числа с плавающей точкой для гуманитариев. Что это такое и как они работают

Введение:  

В этой статье я бы хотел затронуть такую важную и фундаментальную тему для любого программиста, как числа с плавающей точкой. На данную тему уже написано большое количество статей, однако многие из них используют пугающие математические формулы и нотации что может быть сложно для понимания новичкам. В этой статье я простым языком попытаюсь изложить данную тему и помочь решить череду вопросов: как на самом деле процессор хранит числа с плавающей точкой? Как точка хранится в памяти? Почему при сложении 0.1 + 0.3 получается ответ ~0.40000000000000003. Если по какому-то из этих вопросов вы чувствуете что не можете дать точный ответ, то эта статья для вас.

Немного истории:  

В далекие 60–70тые годы отсутствовал единый стандарт для представления чисел с плавающей точкой. Из-за этого программистам приходилось тяжело. Каждая линейка компьютеров того время работала по-разному. В зависимости от процессора, числа с плавающей точкой имели разную точность и округлялись по-разному. Например при умножении числа на 1.0 число могло потерять последние 4 бита точности либо два числа при вычитании могли вернуть ноль, при этом числа были не равны друг-другу. Даже некоторые игры того времени, которые были написаны под определенные модели видеокарт, не работали на других моделях, потому что «железо» по разному обрабатывало числа с плавающей точкой. Для многих это была проблема.

Тогда в 1976 году компания Intel решила разработать собственный сопроцессор для работы с плавающей точкой, для своих микропроцессоров i8086/8. Курировать разработкой был приглашен профессор Джон Палмер. В процессе работы именно он убедил Intel в том, что им нужен свой стандарт для решения этого вопроса. Палмер пригласил своего знакомого Вильяма Кэхэна который первое время выступал как консультант, но в последствии принимал непосредственное участие в работе над стандартизацией. Конкурентами Intel в вопросах стандартизации выступали и другие именитые компании такие как: Zilog, DEC, Motorola. В конечном итоге среди общих стандартов были выбраны 2: VAX от DEC и K-C-S от Intel. Intel до последнего держала в тайне алгоритмы декодирования чисел с плавающей точкой, однако в ходе баталий была вынуждена раскрыть некоторые секреты спецификации устройства и алгоритмов вычисления, что позволило занять главенствующую позицию и их стандарт был утверждён.

Данный стандарт в последствии получил название IEEE-754. Это был стандарт описывающий способ хранения и работу чисел с плавающей точкой, который, казалось бы, раз и навсегда решил все проблемы связанные с числами с плавающей точкой. Но так ли это? Давайте разбираться.

Перевод из десятичной системы счисления в двоичную:  

Для начала стоит отметить, что для процессора в действительности не существует точки, она видна только в конечном итоге визуально, при вводе данных или отображении числа на экране. Но раз процессор не знает что такое точка, как он понимает, когда и в каком месте нам нужно ее поставить? Разработчики стандарта IEEE-754 начали думать, как же решить эту проблему, и в итоге столкнулись с не идеальностью нашей системы исчислении и математики в целом, которую не удалось решить вплоть до сегодняшнего дня.

Для начала попробуем разобраться, как в математике устроены дробные числа в двоичных и десятичных системах, не затрагивая память компьютера. Давайте разберем на примере числа 105.375.

По-другому это число можно записать как 1×102 + 0×101 + 5×100 + 3×10–1 + 7×10–2 + 5×10–3 = 105.375

Число 105.37510

Число 105.37510

Мы можем видеть, что степень основания у целой части растет от 0 к бесконечности, а у дробной части, от -1 до -бесконечности. Так же у нашего числа явно присутствует целая (105) и дробная (0.375) часть.

Давайте возьмем наше число и отделим целую и дробью часть и каждую из частей переведем в двоичный вид.

Для того чтобы перевести целую часть в двоичный вид, мы должны целую часть делить на 2 с остатком, пока результат деления не будет равен нулю. В итоге получаем 10510 = 11010012

Перевод целой части в двоичную систему

Перевод целой части в двоичную систему

Для того чтобы перевести дробную часть числа в двоичный вид, мы должны число умножать на основание степени, до тех пор, пока в целая часть не будет равна 1.0. В нашем случае мы переводим в двоичную систему, значит основание будет равно 2. Число которое получилось в основании мы записываем, а остаток перемножаем вновь и вновь, до того момента пока не получится число 1.0. В итоге получаем 0.37510 = 0.0112

Перевод дробной части в двоичную систему

Перевод дробной части в двоичную систему

Для лучшего понимания можете попробовать самостоятельно перевести числа из десятичной системы в двоичную и обратно

Для примера можем взять число побольше. 0.59375. В двоичном виде это число равно 0.5937510 = 0.100112.

А получили мы это следующим образом:

Перевод числа в двоичную систему

Перевод числа в двоичную систему

С этим разобрались, теперь у нас есть понимание того, как математически можно перевести десятичные цифры в двоичные.

Так будет выглядеть число 105.375 в двоичном виде:

Число 105.3752

Число 105.3752

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

Давайте попробуем:

Перевод 0.9 в двоичную систему

Перевод 0.9 в двоичную систему

Можно видеть, что в определенный момент, результат 0.4 повторяется снова и снова и мы получаем бесконечный цикл. Из этого мы можем сделать вывод, что некоторые числа невозможно перевести из десятичной системы счисления в двоичную, так как дробная часть числа при переводе будет расти до бесконечности. Повторяющуюся дробную часть мы обернем в фигурные скобки, и это будет означать, что повторяется она до бесконечности. Это называется в периоде. 0.910 = 0.11100(1100)2.

Взяв полученное число, давайте попробуем перевести это число обратно, в десятичный вид. И тут сразу можно заметить, что при переводе числа обратно, мы получаем немного отличное от числа 0.9 число. Казалось бы, разные системы счисления и цифры должны быть одинаковые, но ответ при переводе мы получаем разный.

0.11100110012 = (0 × 20) + (1 × 2–1) + (1 × 2–2) + (1 × 2–3) + (0 × 2–4) + (0 × 2–5) + (1 × 2–6) + (1 × 2–7) + (0 × 2–8) + (0 × 2–9) + (1 × 2–10) = 0 + 0.5 + 0.25 + 0.125 + 0 + 0 + 0.015625 + 0.0078125 + 0 + 0 + 0.0009765625 = 0.899414062510

Этот пример, то самое несовершенство математики о котором упоминалось ранее и с которым нам нужно мириться. У некоторые чисел, мы теряем точность и никогда не получим изначальное число, из-за того что часть числа представлено в периоде, (а он бесконечный). Из этого важно запомнить одно правило: некоторые дроби представленные в десятичной системе счисления, невозможно, из-за ограничений математики представить в двоичном виде, как бы мы не старались.

Сохранение числа в память компьютера:  

А теперь давайте перейдем непосредственно к компьютеру. Для сохранения чисел с плавающей точкой, процессор компьютера выполняет следующий алгоритм:  

  1. Переводит число из десятичной в двоичную систему

  2. Получившиеся число переводит в экспоненциальную запись

  3. Число в экспоненциальной записи поместить в 32 бита (для примера будем брать регистр объемом памяти равный 32 битам). 

Для того чтобы понять какие проблемы могут возникнуть, мы попытаемся разобрать этот алгоритм на реальных примерах. Возьмем числа 0.59375, -8.75, 3.9 и попробуем их перевести в двоичный вид.

Начнем с числа -8.75. Переведем целую участь числа и дробную в двоичный вид. В результате мы получим число -8.7510 = -1000.112

Число 0.5937510 = 0.100112 тоже переводится без особых проблем

А вот число 3.9, как мы видим, перевести в двоичный вид мы не можем, поэтому запишем это число в периоде 3.910 = 11.11100(1100)2.

Так мы выполнили первый этап алгоритма. Перевели числа из десятичной в двоичный вид. Далее попробуем записать эти числа в экспоненциальной записи:

Если попытаться объяснить что такое экспоненциальная запись простым языком не используя такие слова как мантисса и порядок, то я бы описал это так:

При экспоненциальной запись берется произвольное число, каким бы большим или маленьким оно не было, оставляется одна цифра перед точкой, а все остальное записывается как дробная часть в виде степени.

Зачем нам нужна экспоненциальная запись

Если у нас слишком большие или маленькие цифры, например число 10000000000, то его проще записать как 1×1010. Экспоненциальной запись позволяет записывать очень длинные цифры в компактном виде. А степень над множителем просто указывает точность.

Далее давайте попробуем привести наши получившиеся числа в двоичной системе в экспоненциальный вид. На примере числа -8.7510 = -1000.112 мы должны сдвинуть точку на 3 разряда влево. Так у нас получится число -1.000112×103.

Теперь число 0.5937510 = 0.100112. Тут все тоже самое за исключением того, точку двигаем в право так как степень положительная. В данном случае мы получим число 1.00112×10–1

И последнее число 3.910 = 11.11100(1100)2 после перевода превращается 1.111100(1100)2×101.

Итого у нас получилось 3 числа в следующем виде:

-8.7510 = -1.000112×103

0.5937510 = 1.00112×10–1.

3.910 = 1.111100(1100)2×101

Теперь нам остался последний этап алгоритма — записать полученные числа в память. Как нам это сделать?  Для примера, как упоминалось ранее, возьмем объем памяти равным 32 бита. 

-8.7510 = -1.000112*103

-8.7510 = -1.000112×103

0.5937510 = 1.00112 * 10-1

0.5937510 = 1.00112×10–1

3.910 = 1.111100(1100)2 * 101

3.910 = 1.111100(1100)2×101

Первый бит отводится под знак, как и в целых числах. Получается если наше число меньше нуля, то в этот бит будет записана единица, и ноль в обратном случае.

Записав знак в старший бит, у нас остается еще 31 бит для того чтобы записать остаток числа. Для этого, разработчики стандарта IEEE-754 решили, что они выделят 23 бита для хранения дробной части (F — с английского fraction). Однако тут сразу стоит заметить, что в экспоненциональной записи, целая часть нашего числа, которое мы заранее перевели в двоичный вид, всегда равна единице. Соответсвенно в стандарте решили, что нет смысла записывать эту единицу и тратить на нее лишний бит, поэтому при записи числа в память, целую часть, просто опускают. Это позволяет увеличить точность на 1 бит. 

Записав всю дробную часть в отведенные 23 бита и опустив единицу, мы получаем следующий вид:

-8.7510 = -1.00011*103

-8.7510 = -1.00011×103

0.5937510 = 1.00112 * 10-1

0.5937510 = 1.00112×10–1

3.910 = 1.111100(1100)2 * 101

3.910 = 1.111100(1100)2×101

Мы можем видеть, что дробная часть записывается слева-направо, а все незанятое пространство мы заполняем нулями. 

С числами 0.59375 и -8.75 все понятно. А как быть с числом 3.9 у которого дробная часть в двоичном виде записывается в периоде, который по своей сути бесконечный? На разных машинах поведение может отличаться, но в общем алгоритмм следующий: мы будем записывать значения до тех пор, пока у нас не закончится место, и та дробная часть, которая поместилась и будет означать нашу точность.

Из этого мы можем сделать вывод, что компьютер записывает числа с плавающей точкой с определенной точность, так как физически невозможно записать число в периоде, который потенциально бесконечный. Однако у нас есть возможность повысить эту точность, увеличив объем памяти. Например, до 64 бит, в таком случае стандарт выделяет 52 бита для записи дробной части числа.

Теперь нам остался последний шаг: нужно записать степень, которая даст понять, насколько порядков мы сдвинули точку, переводя число в экспоненциональный вид. Под хранение степени ученые решили выделить оставшиеся 8 бит (E — с английского exponent).

Но прежде чем записать оставшуюся часть, давайте еще раз глянем на наши цифры в экспоненциональном виде: -1.000112×103, 1.00112×10–1 и 1.111100(1100)2×101. Можно заметить, что основание степени всегда равно 10. В первом примере мы имеем 103, во втором 10–1 и в третьем 101. Поэтому так же как с целой частью числа, мы основании степени хранить не будем. Остается только сама степень.

У внимательных читателей мог возникнуть вопрос: «А что делать если у нас степень со знаком минус? Что делать в таком случае? Будет ли выделен 1 бит из этих 8 бит под знак, как и с целыми числами?»

Давайте попробуем реализовать это решение на примере двух чисел -0.7510 = 1.12×10–1 и 410 = 1.02×102. и в качестве первого бита из 8 выделенных бит будем записывать знак. Тогда мы получим следующее

-0.7510 = 1.12 * 10-1

-0.7510 = 1.12×10–1

410 = 1.02 * 102

410 = 1.02×102

И тут сразу можно заметить две проблемы. Первая заключается в том, что процессор при сравнении двух чисел, будет побитово проходится по ним и сравнивать значения. Дойдя до числа с отрицательным битом, он увидит там единицу и решит, что это число больше, и операция сравнения будет неправильная.
А вторая проблема заключается в том, что мы выделяем один бит на хранения знака, из-за чего наша и точность станет ниже.

Для решения этих проблем ученые придумали очень хитрое решение, а заключалось оно в следующем: 82 это диапазон чисел от 0 до 255. И тогда было принято взять этот диапазон и разделить его на два, получившиеся число посередине — 12710 = 011111112 стали воспринимать как 0. Все числе больше 127 воспринимались процессором как положительные, а меньше 127 как отрицательные. Соответсвенно у нас появился диапазон чисел от 0–127 с минусом, и от 127–255 с плюсом, что позволяло записывать числа с точностью до 10127.

Note: Если быть точным, то диапазон положительных был от 0 до 126, а для отрицательных от 0 до -127, но об этом позже.

Для наглядности возьмем те же 2 числа, -0.7510 = 1.12×10–1 и 410 = 1.02×102. Теперь, по формуле степень сдвига точки будет записана следующим образом:

-0.7510 = 1.12 * 10-1

-0.7510 = 1.12×10–1

410 = 1.02 * 102

410 = 1.02×102

Соответсвенно когда процессор попытается сохранить число, он сделает следующее: возьмет 12710 = 011111112 и добавит к нему число степени в двоичном виде. И теперь при попытке сравнить эти 2 числа, процессор будет выдавать результаты корректно и то, как теперь, наши числа сохранены в памяти, помогает компьютеру легко их сравнивать и расходовать память более экономно.

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

-8.7510 = -1.00011*103

-8.7510 = -1.00011×103

0.5937510 = 1.00112 * 10-1

0.5937510 = 1.00112×10–1

3.910 = 1.111100(1100)2 * 101

3.910 = 1.111100(1100)2×101

Специальные значения:

Наверняка в программе, при работе с числами с плавающей точкой, вы сталкивались со специальными значениями, такие как NaN, ±Infinity. Для того чтобы отобразить эти значения, ученые решили позаимствовать пару комбинаций битов в экспоненте для отображения этих специальных значений. Так, например, для того чтобы отобразить Inf, экспонента должна содержать все единицы, а в дробной части, должны быть все нули. Положительная либо отрицательная будет бесконечность, зависит от того, какой бит записан в старший бит знака:

-Infinity

-Infinity

+Infinity

+Infinity

Так же стоит упомянуть NaN (Not a Number), который используется для обозначения не существующего числа. Например если мы пытаемся получить корень из отрицательного числа. В данном случае в экспоненту так же будут записаны все единицы, а вот знаковый бит и биты дробной части могут иметь произвольные значения.

NaN

NaN

По причинам описанные выше, положительная степень числа может храниться от 127–254 значений, потому что 25510 = 111111112 занято под специальное значение.

Заключение:

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


В продолжение к чтению, если вдруг интересно почитать как компьютер работает с Unicode простым языком, вот отличная статья на эту тему.

© Habrahabr.ru