← Все статьиНаписать

IEEE 754 простыми словами: почему 0.1 + 0.2 не равно 0.3

Когда я разбирался, почему в интернет-магазине не сходятся копейки, всё упёрлось в IEEE 754 — стандарт, по которому процессор хранит дробные числа. Здесь объясняю, как он устроен изнутри: из каких частей состоит число, откуда берётся погрешность, что такое NaN и почему тип данных float не годится для денег.

Пост, на основе которого написана статья: Число с плавающей точкой и деньги: как не получить недостачу в кассе

Откуда взялся стандарт и зачем он нужен

До середины 1980-х каждый производитель процессоров считал дробные числа по-своему. Один и тот же код на разных машинах давал разные результаты, а перенос научных программ превращался в отдельный проект. Первую версию стандарта приняли в 1985 году, над ней много работал математик Уильям Кэхэн. Позже вышли пересмотры 2008 и 2019 годов.

Стандарт описывает не только формат хранения. В нём есть правила арифметики: как складывать и умножать, как округлять, что возвращать при делении на ноль и переполнении. Поэтому 0.1 + 0.2 в JavaScript, Python и Java даёт один и тот же ответ 0.30000000000000004.

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

Из чего состоит число с плавающей точкой

Число хранится в двоичном виде, похожем на научную запись: 1,5 × 10³, только по основанию 2. Отсюда и название — точка «плавает», её положение задаёт отдельное поле.

Знак, порядок и мантисса

Каждое число состоит из трёх частей. Один бит знака говорит, положительное оно или отрицательное. Порядок (экспонента) задаёт, на какую степень двойки умножить значение. Мантисса хранит значащие цифры.

Для обычных чисел старший бит мантиссы всегда равен единице, поэтому его не хранят и экономят разряд. Порядок записывают со смещением: для 32-битного формата к реальной степени прибавляют 127, для 64-битного — 1023. Так отрицательные степени обходятся без отдельного знака.

Тип данных float и double: форматы binary32 и binary64

В языках программирования форматы стандарта обычно называются привычнее. Тип данных float в C, Java и C# — это binary32, double — binary64. В JavaScript все числа по умолчанию binary64, в Python тип float тоже 64-битный. Отдельного 32-битного типа в Python нет.

Точность считают в десятичных цифрах, которые переживают запись и обратное чтение без искажений.

ФорматВсего битПорядокМантиссаТочность, десятичных цифр
binary32 (float)32823около 7
binary64 (double)64115215–17
binary12812815112около 34

Почему 0.1 нельзя записать точно

В десятичной системе нельзя конечной записью выразить 1/3: получается 0,3333 и так далее. В двоичной та же беда у 1/10. Одна десятая в двоичном виде — бесконечная периодическая дробь 0,000110011001100 и так далее

Мантисса конечна, поэтому дробь обрезают и округляют до ближайшего представимого числа. В памяти лежит не 0.1, а чуть больше. С 0.2 то же самое. При сложении двух приближений результат снова округляется, и ближайшим к сумме оказывается не то число, которое мы привыкли видеть как 0.3, а соседнее.

Ошибка крошечная, около 10⁻¹⁷. Но в расчётах она не исчезает, а накапливается: каждая операция добавляет свою долю. Тысяча сложений в цикле — и расхождение видно уже в копейках.

Правила округления

Стандарт задаёт пять режимов округления. По умолчанию действует округление к ближайшему, а при равном расстоянии — к чётному. Ещё есть округление от нуля при равенстве, к нулю, к плюс и к минус бесконечности.

Округление к чётному называют банковским. На нём многие спотыкаются: функция round в Python превращает 2.5 в 2, а 3.5 — в 4. Математически это честнее, потому что ошибки при массовом округлении не копятся в одну сторону. Бухгалтер, который ждёт школьного правила «пять — вверх», увидит расхождение.

Особые значения: ноль со знаком, бесконечность и NaN

Кроме обычных чисел стандарт вводит особые. Есть два нуля, +0 и −0: при сравнении они равны, но ведут себя по-разному при делении. Есть плюс и минус бесконечность — результат переполнения или деления ненулевого числа на ноль. В JavaScript 1 / 0 вернёт Infinity, а не ошибку.

NaN, «не число», возникает там, где результата нет: 0 / 0, корень из отрицательного числа, бесконечность минус бесконечность. У NaN странное свойство — он не равен ничему, даже самому себе. Проверять его нужно функцией isnan или её аналогом, а не сравнением.

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

Чем опасен NaN в расчётах

NaN распространяется молча. Любая арифметика с ним даёт NaN, и одна битая строка в данных превращает итог отчёта в «не число». Хуже, когда такое значение уходит дальше: формат JSON по RFC 8259 не допускает NaN и Infinity, и сериализатор либо упадёт, либо подставит null, а модуль json в Python по умолчанию вообще запишет NaN как есть, и другой парсер такую строку не прочитает. Я бы проверял входные данные на границе системы, а не надеялся, что расчёт как-нибудь переживёт.

Float, double или decimal: что выбрать

Двоичные форматы придумали ради скорости, и для большинства задач они хороши. Графика, физика в играх, машинное обучение, данные с датчиков — везде, где важна скорость и допустима погрешность в последних знаках.

Для денег нужна десятичная точность. В базах данных это типы DECIMAL и NUMERIC с заданным числом знаков после запятой. В языках — BigDecimal в Java, decimal в C#, модуль decimal в Python. В пересмотре 2008 года появились и десятичные форматы с плавающей точкой, decimal64 и decimal128, но в массовых языках их поддержка ограничена.

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

  • Не сравнивайте дробные числа через ==, сравнивайте разницу с допуском.
  • Не накапливайте денежные суммы во float и double.
  • Округляйте явно и одним способом во всей системе.
  • Переводите в двоичный формат только для показа на экране, графиков и аналитики.

Как проверить, понимает ли команда тему

Вопрос «почему 0.1 + 0.2 не равно 0.3» хорошо работает на собеседовании и при отборе подрядчика. Нормальный разработчик уровня миддл объяснит про двоичную дробь и предложит decimal или хранение в целых. Если в ответ звучит «это баг PHP» — разговор о работе с деньгами лучше не продолжать.

Вопросы и ответы

Что такое тип данных float?

Это 32-битное число с плавающей точкой по стандарту, формат binary32. Оно держит около семи значащих десятичных цифр и подходит для графики и вычислений, где мелкая погрешность не страшна.

Почему 0.1 + 0.2 не равно 0.3?

Одна десятая и две десятых в двоичном виде — бесконечные дроби. Их обрезают до 52 бит мантиссы, и сумма двух приближений округляется до 0.30000000000000004.

Чем double отличается от float?

Double — 64-битный формат binary64: 11 бит на порядок, 52 на мантиссу и точность 15–17 десятичных цифр. Float вдвое короче и точнее примерно до семи цифр.

Можно ли хранить деньги в double?

Я бы не стал: погрешность накапливается при сложении, скидках и умножении. Для денег берут DECIMAL в базе, BigDecimal или decimal в коде либо хранят суммы в целых копейках.