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

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

У цен в разработке есть проблема — копейки. Число с плавающей точкой при передаче через 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 он есть из коробки.

Обсудить в Telegram Подписаться на @altocodes