Число с плавающей точкой и деньги: как не получить недостачу в кассе
У цен в разработке есть проблема — копейки. Число с плавающей точкой при передаче через API превращается то в int, то в float, а в PHP 0.1 + 0.2 даёт 0.30000000000000004. Разбираю, при чём тут стандарт IEEE 754 и как хранить деньги: в целых числах, строках или через bcmath и BigInt.

У цен в разработке есть проблема — копейки. Те самые числа после запятой.
1. При передаче через API или json число со знаком может превратиться в целочисленное (int), так и в дробное (float). 1.00 -> 1. Из-за этого при сравнении типов дальше в коде может вылезти ошибка.
2. При вычислениях с числами накапливаются погрешности. Если сложить в php 0.1 + 0.2, то получится, не 0.3, а 0.30000000000000004
Казалось бы какая разница. Но в интернет-магазине числа могут умножаться, суммироваться, применяться скидки — рано или поздно погрешность округлиться не в ту сторону. Не сойдется копейка, а с ней вся бухглатерия. А еще есть крипта, где может быть сильно больше знаков после запятой.
Почему так: стандарт IEEE 754
Есть стандарт IEEE 754. У него есть плюс, он офигенно быстро работает. И подходит для большинства задач, кроме денежных. Если бы не этот стандарт, то все компьютеры бы работали медленнее. Это всё особенности хранения памяти.
Как накапливаются погрешности
Есть, кстати, байка про ракету в 91 году, которая накопила погрешности, отклонилась от курса и не смогла защитить от вражеской ракеты. Мы с вами не ракеты строим (я надеюсь), но бухглатерия, которая будет 3 дня искать ошибку — ощутимая потеря.
Как хранить деньги без чисел с плавающей точкой
1. Хранить всё в целых числах 1234,10 -> 123410 и при отображении отделять копейки. Или в строках.
2. Все вычисления делать в случае с пхп, через библиотеку bcmath. В случае с другими языками использовать BigInt. В Java он, например из коробки.
В 1С, кстати, такой проблемы нет. Возможно поэтому она вечно тормозит :)
——————————
Вопрос для собеседования и отбора подрядчика
Это может быть хорошим вопросом при собеседовании или отборе подрядчика. Так как любой нормальный миддл — знает ответ на вопрос, он много раз разжевывался на Хабре. А тот кто только закончил курсы и притворяется опытным, в спешке «сеньор за 3 месяца» про такие детали забудет.
Как устроен формат IEEE 754
Стандарт IEEE 754 приняли в 1985 году, последняя редакция вышла в 2019-м. Самый ходовой формат — binary64, он же double. В нём 64 бита: 1 бит знака, 11 бит порядка и 52 бита мантиссы. Это даёт 15–17 значащих десятичных цифр.
Значение хранится как мантисса, умноженная на два в степени порядка. Отсюда и проблема. Дробь 1/10 в двоичной системе бесконечная, как 1/3 в десятичной: 0.0001100110011 и так до бесконечности Её приходится обрезать, и в памяти лежит не 0.1, а ближайшее к нему значение.
Каждая операция добавляет свою крошечную ошибку. Одно сложение незаметно. Тысяча сложений в корзине со скидками — уже копейка.
Почему 0.1 + 0.2 не равно 0.3 в любом языке
Это не баг PHP. Тот же результат выдаст JavaScript, Python, Java и C#, потому что все они используют аппаратную арифметику процессора по IEEE 754. Сайт 0.30000000000000004.com собирает примеры для десятков языков.
Сравнивать такие значения через == нельзя. Если без float не обойтись, их сравнивают с допуском: модуль разницы меньше заранее выбранного эпсилона.
Где копейки теряются в интернет-магазине
В чеке онлайн-кассы по 54-ФЗ итог должен сходиться с суммой позиций до копейки. Если после распределения скидки сумма разошлась на 1 копейку, касса может не пробить чек, и заказ зависнет.
Отдельная история — JSON. Стандарт RFC 8259 не ограничивает точность чисел, но большинство парсеров читают их как double. Поэтому суммы в API надёжнее передавать строкой «1234.10» или целым числом в минимальных единицах. Так устроен, например, API Stripe: поле amount передаётся в минимальных единицах валюты, для доллара — в центах.
- Скидка в процентах на корзину, которую нужно разнести по позициям чека.
- Цена за единицу при весовом товаре: 0,347 кг по 589,90 руб.
- Пересчёт валют и курсы с четырьмя-шестью знаками после запятой.
- НДС, выделенный из суммы, а не начисленный сверху.
- Частичный возврат одной позиции из заказа со скидкой.
- Передача суммы в платёжный шлюз, онлайн-кассу и 1С разными форматами.
Распределение остатка при скидке
Скидку 100 рублей на три одинаковые позиции нельзя поделить поровну: получится 33,33 × 3 = 99,99. Правильный приём — считать доли в копейках и отдать остаток в 1 копейку одной из позиций, обычно самой дорогой. Так делают библиотеки вроде Money for PHP (moneyphp) и Dinero.js, у них есть готовый метод allocate.
Как хранить деньги в базе данных: сравнение способов
В MySQL и PostgreSQL я бы выбирал DECIMAL с двумя знаками для рублей и с большим числом знаков для криптовалют: у биткоина 8 знаков после запятой, у эфира — 18. Тип money в PostgreSQL зависит от настроек локали сервера, поэтому его обычно обходят стороной.
| Способ | Точность | Где подходит | Риски |
|---|---|---|---|
| FLOAT / DOUBLE | Приблизительная | Координаты, физика, графика | Копейки теряются при суммах и скидках |
| DECIMAL(15,2) / NUMERIC | Точная | Цены, суммы заказов, учёт | Нужно заранее выбрать число знаков |
| BIGINT в копейках | Точная | Высоконагруженные расчёты, биллинг | Легко забыть деление на 100 при выводе |
| Строка | Точная при передаче | JSON, обмен с внешними API | Считать в строках нельзя, нужна конвертация |
Точные вычисления в разных языках
- PHP: расширение bcmath — bcadd, bcmul, bcdiv с явным параметром scale.
- Java: BigDecimal с режимом округления RoundingMode.HALF_UP или HALF_EVEN.
- Python: модуль decimal из стандартной библиотеки.
- JavaScript: BigInt для целых копеек или библиотеки decimal.js и big.js.
- 1С: тип Число с заданной точностью, отсюда и отсутствие проблемы.
Округление: банковское и обычное
Режим округления по умолчанию в IEEE 754 — к ближайшему чётному, его ещё называют банковским: при округлении до целого 2,5 даёт 2, а 3,5 — 4. Это банковское округление, оно уравновешивает ошибки на больших объёмах. Функция round() в PHP по умолчанию округляет половину от нуля, то есть 2,5 даёт 3.
Когда сайт считает так, а 1С иначе, суммы в части заказов расходятся на копейку. Правило округления нужно зафиксировать одно на всю систему и прописать в ТЗ.
Подробнее по темам
Вопросы и ответы
Почему 0.1 + 0.2 не равно 0.3?
Из-за того, как дробные значения хранятся в памяти по стандарту IEEE 754: 0.1 и 0.2 нельзя точно записать в двоичном виде. В PHP 0.1 + 0.2 даёт 0.30000000000000004.
Как хранить деньги в коде и базе данных?
В целых числах: 1234,10 превращается в 123410, а копейки отделяются при отображении. Или в строках.
Как считать деньги в PHP без погрешности?
Все вычисления делать через библиотеку bcmath. В других языках использовать BigInt — в Java он есть из коробки.