Проблема вещественных чисел в программировании

Сегодня мы поговорим о вещественных числах. Точнее, о представлении их процессором при вычислении дробных величин.

· 6 минуты на чтение
Проблема вещественных чисел в программировании

Если вы только начинаете изучать программирование, то можете столкнуться с "неочевидным" поведением ваших программ. Особенно может сбивать с толку работа с вещественными числами.

Наверное самый популярный пример иллюстрирующий эту проблему это 0.1 + 0.2 == 0.3. Для человека очевидно, что 0.1 + 0.2 это 0.3, а значит это логическое выражение должно давать true. Однако, если вы запустите это в Java, то результат вас удивит.

System.out.println(0.1 + 0.2 == 0.3); // false

Это выражение выводит false?! Почему? Давайте выведем на консоль результат выражения 0.1 + 0.2.

System.out.println(0.1 + 0.2); // 0.30000000000000004

Вместо ожидаемых 0.3 мы получили 0.30000000000000004. Не спешите бросать Java и учить новый язык, это будет преследовать вас во всех языках программирования.

Всеми любимый JavaScript

Как компьютер хранит данные

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

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

0:00
/
Визуализация перевода 6.25 в двоичную систему счисления

Таким образом мы получаем, что 6.25 = 110.01 в двоичном виде, но запятая то у нас осталась. Для этой запятой нет представления в мире нулей и единиц, поэтому компьютер не сможет сохранить такой результат в память.

Спонсор поста

Стандарт IEEE-754

Нужно было придумать формат, который позволит преобразовывать подобные числа в простую строку нулей и единиц, без всяких знаков. В поисках компромисса между скоростью, размером и точностью представления ученые предложили представлять вещественные числа в виде чисел с плавающей запятой. Этот стандарт называется IEEE-754 (wiki).

Наше двоичное число нам необходимо представить в виде формулы:

(-1)s × M × BE,

где s — знак числа, M — мантисса, B — основание, E — экспонента. Так как мы работаем в двоичном коде, то основание у нас равно двум, а формула принимает вид:

(-1)s × M × 2E.

Разбираемся на пальцах 🖐

Все это звучит сложно, какие-то формулы, но на самом деле все довольно легко.

Условно вещественное число мы должны сохранить как три различных числа: знак, экспонента и мантисса. Количество бит для сохраненения всегда ограничено форматом. Мы будем рассматривать формат, в котором нам доступно 32 бита: 1 бит знака, 8 битов под экспоненту, и 23 бита под мантиссу. Такой формат называется одинарной точностью.

Схематично можно представить это в памяти вот так.

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

Из-за этих сдвигов запятой и появилось название числа с плавающей точкой 🙃
0:00
/
Почему основание 10, а не 2?

Это не 10, это представление 2 в двоичной системе счисления. Для удобства восприятия, я не стал переводить в двоичную систему счисления степень этой двойки.

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

В итоге мы получили 1.1001 x 22. И 1.1001 это наша мантисса. Наше число положительное, а поэтому бит знака будет равен 0.

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

Эту единицу ПК всегда "держит" в уме

Проще говоря, нормализованное число имеет следующий вид:

(-1)s × 1.M × 2E

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

0:00
/

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

Почему нужно прибавить 127?

Под экспоненту у нас выделено 8 бит, что позволяет нам сохранить 256 значений. Так как степень может быть положительной и отрицательной, нужно как-то сохранить ее знак, а еще одного бита под знак здесь не предусмотренно.

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

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

Получается, что все отрицательные степени будут находиться слева от числа 127, а все положительные справа.

Итак, прибавляем 127, получаем 129. Преобразуем 129 в двоичную систему счисления, вот и получили мантиссу.

0:00
/

Также запишем бит знака, напомню, что для положительных чисел этот бит равен 0, а для отрицательных 1.

0:00
/

У нас получилось перевести вещественное число в двоичное представление. Кажется, никаких проблем нет. Проблем не было, потому что в качестве примера было выбрано "удачное" число.

Непреобразуемые числа

Теперь попробуем преобразовать число 5.9. Данное число перевести в двоичную систему счисления невозможно, как невозможно записать результат 1/3 в десятичной системе счисления. В итоге нам остается только записать значение в периоде: 5.9 = 101.11100(1100).

Но нам в любом случае это число нужно как-то записать в память, которая напомню имеет размер в 32 бита. Это у нас получится только путем отсечения части числа, которая в 32 бита не помещается.

Мы записали максимально возможно количество повторений периода. А теперь восстановим из этой записи десятичное число.

5.89999961

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

До этого мы рассматривали ситуацию, когда у нас было 32 бита для записи, этот размер называется одинарной точностью. В Java за этот формат отвечает float. Но мы можем увеличить это количество в два раза до 64 бит, это уже будет двойная точность. В Java этот формат называется double. Чем больше бит у нас будет для записи, тем более точное число мы сможем записать. Но восстановить из них исходное число все равно не удастся.

В математике между 1.0 и 2.0 существует бесконечное количество вещественных чисел. Однако память компьютера способна точно сохранить всего 8_388_609 чисел находящихся между 1.0 и 2.0 во float.

Неточности при вычислениях

Давайте вернемся к нашему примеру 0.1 + 0.2. Если вы попробуете преобразовать 0.1 и 0.2 в двоичную систему счисления, то обнаружите, что эти числа также не переводятся в двоичную систему счисления. С помощью специального точного калькулятора получим нормализованные значения, но не в двоичной, а в десятичной системе счисления.

0.1 = 0.1000000000000000055511151231257827021181583404541015625
0.2 = 0.200000000000000011102230246251565404236316680908203125

То есть это максимально близкие числа, которые способен запомнить компьютер. Если мы их суммируем, то получим:

0.3000000000000000166533453693773481063544750213623046875

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

То число, которое мы получили в сумме, также не может быть сохранено, а ближайшее число, которое компьютер может сохранить это

0.3000000000000000444089209850062616169452667236328125.

Отображение 0.1 в дебаге

Однако, вы можете справедливо заметить, что во время дебага в переменной будет отображаться 0.1, а не 0.10...55.

Но это просто визуализация данных. Давайте посмотрим, что на самом деле хранится в памяти этой переменной.

Ничего необычного, знак, экспонента и мантисса. Если преобразовать это в десятичное число, то мы получим 0.10000000149012, но видимо IDEA еще дополнительно округляет значение до 0.1.

Проведем еще один небольшой эксперимент. Найдем минимальное число, которое не будте округляться до 0.1.

Выводы

программирование — это не математика

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

И скорее всего у вас есть закономерный вопрос: как мы ракеты в космос запускаем с такими расчетами?! Или как проходят финансовые операции?

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

На этом у меня все. Вот такой вот удивительный мир программирования.

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇