Введение
В этой статье я бы хотел затронуть такую важную и фундаментальную тему для любого программиста, как числа с плавающей точкой. На данную тему уже написано большое количество статей, однако многие из них используют пугающие математические формулы и нотации что может быть сложно для понимания новичкам.
В этой статье я простым языком попытаюсь изложить данную тему и помочь решить череду вопросов: как на самом деле процессор хранит числа с плавающей точкой? Как точка хранится в памяти? Почему при сложении 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
Мы можем видеть, что степень основания у целой части растет от 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 в двоичном виде:
Прежде чем пойти дальше и разобрать, как это все хранится в памяти процессора, нужно подсветить одну вещь, что числа которые мы переводили до этого момента, очень легко поддавались переводу. Однако, что будет если мы попытаемся перевести число 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
Этот пример того самого несовершенства математики о котором упоминалось ранее, и с которым нам нужно мириться. У некоторые чисел теряется точность и мы никогда не получим изначальное число из-за того что часть числа представлено в периоде (а он бесконечный). Из этого важно запомнить одно правило: некоторые дроби представленные в десятичной системе счисления невозможно из-за ограничений математики представить в двоичном виде. Именно из-за этого при попытке сложить числа 0.1 + 0.2 мы получаем странный результат. Их просто невозможно перевести в двоичный вид сохранив точность. Из-за этого результат сложения выглядит не совсем очевидным, так как после перевода чисел из десятичную систему в двоичную и обратно, была потеряна точность числа.
Сохранение числа в память компьютера
А теперь давайте перейдем непосредственно к компьютеру. Для сохранения чисел с плавающей точкой процессор компьютера выполняет следующий алгоритм:
Переводит число из десятичной в двоичную систему
Получившиеся число переводит в экспоненциальную запись
Число в экспоненциальной записи поместить в 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 бита.
Первый бит отводится под знак, как и в целых числах. Получается если наше число меньше нуля, то в этот бит будет записана единица, и ноль в обратном случае.
Записав знак в старший бит, у нас остается еще 31 бит для того чтобы записать остаток числа. Для этого, разработчики стандарта IEEE-754 решили, что они выделят 23 бита для хранения дробной части (F - с английского fraction). Однако тут сразу стоит заметить, что в экспоненциональной записи, целая часть нашего числа, которое мы заранее перевели в двоичный вид, всегда равна единице. Соответсвенно в стандарте решили, что нет смысла записывать эту единицу и тратить на нее лишний бит, поэтому при записи числа в память, целую часть, просто опускают. Это позволяет увеличить точность на 1 бит.
Записав всю дробную часть в отведенные 23 бита и опустив единицу, мы получаем следующий вид:
Мы можем видеть, что дробная часть записывается слева-направо, а все незанятое пространство мы заполняем нулями.
С числами 0.59375 и -8.75 все понятно. А как быть с числом 3.9 у которого дробная часть в двоичном виде записывается в периоде который по своей сути бесконечный? На разных машинах поведение может отличаться, но в общем алгоритмм следующий: мы будем записывать значения до тех пор, пока у нас не закончится место, и та дробная часть которая поместилась в отведенные 23 бита и будет означать нашу точность.
Из этого мы можем сделать вывод, что компьютер записывает числа с плавающей точкой с определенной точность, так как физически невозможно записать число в периоде, который потенциально бесконечный. Однако у нас есть возможность повысить эту точность, увеличив объем выделенной памяти с 32 до 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 выделенных бит будем записывать знак. Тогда мы получим следующее
И тут сразу можно заметить две проблемы. Первая заключается в том, что процессор при сравнении двух чисел, будет побитово проходится по ним и сравнивать значения. Дойдя до числа с отрицательным битом, он увидит там единицу и решит, что это число больше, и операция сравнения будет неправильная.
А вторая проблема заключается в том, что мы выделяем один бит на хранения знака из-за чего наша и точность станет ниже.
Для решения этих проблем ученые придумали очень хитрое решение, а заключалось оно в следующем: 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. Теперь по формуле степень сдвига точки будет записана следующим образом:
Теперь когда процессор попытается сохранить число, он сделает следующее: возьмет 12710= 011111112 и добавит к нему число степени в двоичном виде. И теперь при попытке сравнить эти 2 числа, процессор будет выдавать результаты корректно. Наши числа сохранены в памяти таким образом, что процессору легче их сравнивать и память расходуется более экономно.
Вернемся к нашим числам. После применения последнего шага алгоритма, наши числа будут выглядеть следующим образом:
Специальные значения
Наверняка в программе, при работе с числами с плавающей точкой, вы сталкивались со специальными значениями такие как NaN, +-Infinity. Для того чтобы отобразить эти значения, ученые решили позаимствовать пару комбинаций битов в экспоненте для отображения этих специальных значений. Например для того чтобы отобразить Inf, экспонента должна содержать все единицы, а в дробной части должны быть все нули. Положительная либо отрицательная будет бесконечность, зависит от того какой бит записан в старший бит знака:
Так же стоит упомянуть NaN (Not a Number), который используется для обозначения не существующего числа. Например если мы пытаемся получить корень из отрицательного числа. В данном случае в экспоненту так же будут записаны все единицы, а вот знаковый бит и биты дробной части могут иметь произвольные значения.
По причинам описанные выше, положительная степень числа может храниться от 127-254 значений, потому что 25510 = 111111112 занято под специальное значение.
Заключение
В этой статье мы разобрали основные проблемы перевода чисел из десятичной в двоичную систему, принципы того как компьютер работает с числами с плавающей точкой и какие проблемы могут возникнуть и как с этим всем пытаются бороться программисты.
В продолжение к чтению, если вдруг интересно почитать как компьютер работает с Unicode простым языком, вот отличная статья на эту тему.
Комментарии (19)
rmrfchik
03.07.2023 18:23+5Остались нераскрытыми вопросы: почему и куда точка плавает? что за видеокарты в 60-70, из-за особенностей вычислений которых игры не работали?
Altaev
03.07.2023 18:23Поддержу! Суть плавающей точки не раскрыта. Вроде как, «информатика для самых маленьких», но основной момент протерян в дебрях статьи за выдержками из всего подряд. Про видеокарты 60-х - огонь! Вся статья - кривой перевод, либо нейросетевой шедевр, имхо.
Maksimilliano Автор
03.07.2023 18:23+1Спасибо за комментарий. Это происходит из-за того, что при переводе чисел из десятичной в двоичную систему счисления и обратно, теряется точность о которой говорилось выше. Добавил более точное уточнение!
rmrfchik
03.07.2023 18:23+1Нет, плавающая запятая не потому, что при переводе "теряется точность", а потому, что количества знаков после запятой зависит от экспоненты, в отличие от fixed point, где количество знаков определено заранее. Запятая "плавает".
Maksimilliano Автор
03.07.2023 18:23Все верно. "Теряется точность" это про то, почему у нас появляются проблемы при работе с определенными дробями из десятичной системы
sergeyKhaladji
03.07.2023 18:23+3Отличное объяснение. Легко воспринимается. Помогло, наконец, полностью понять идею. Критики предлагают добавлять в статью несущественные детали и исторические данные, которые будут только мешать дочитать статью до конца.
MSerhiy
03.07.2023 18:23+2Спасибо, доходчиво. Еще стоит привести пример с большими числами, что бы показать что скажем +1.0 может оставить число без изменений, как можно реализовать операции над числами с плавающей запятой. Также можно объяснить как "накапливание" данных может влиять на точность.
Maksimilliano Автор
03.07.2023 18:23+1Спасибо большое за комментарий. Да, идея отличная, нужно подумать куда это лучше добавить. Изначально хотелось сократить статью как можно сильнее, чтобы не увеличивать когнитивную нагрузку и дать как можно меньше материала в "понятной" и "простой" форме
PereslavlFoto
03.07.2023 18:23куда это лучше добавить
В следующую статью, ибо вопрос про накапливание данных вполне самостоятельный.
Videoman
03.07.2023 18:23+1Всё хорошо в объяснении, только вот экспонента (порядок) это степень двойки, а не десятки. Неплохо бы было объяснить и это момент.
sergio_nsk
03.07.2023 18:23+2Очень сложно читать. Основания чисел пишутся в нижнем регистре, а не в верхнем, в котором пишутся степени. В тексте не поймёшь, где степени и где основания.
из-за ограничений математики
Это в корне не верно. Математика здесь ничем не ограничивает. Ограничение возникает из модели представления вещественного числа с двоичным основанием. Ну или можно было сказать из-за ограничений двоичной арифметики.
unclegluk
03.07.2023 18:23Я вот не понял про перевод в десятичную систему. Каким образом 1*2-1 превратилось в 0.5?
tzlom
Целые числа тоже хранятся в дополненным коде, а не с битовым знаком, как упомянуто в статье. IEEE-754 определяет разные виды NaN, об этом тоже стоит упомянуть.
Maksimilliano Автор
Спасибо за комментарий. Вы правы. Но цель статьи, как написано в начале, помочь новичкам разобраться с данной темой "на пальцах" без сложных слов и формул
tzlom
чего сложного в NaN я не понял, а дополненный код вы и так описали т.к. порядок хранится в нем.