Граждане! Храните деньги в сберегательной кассе!
— Жорж Милославский
В рамках этой статьи я рассматриваю проблему с некорректным использованием float
на примере языка PHP, хотя та же проблема нередко встречается и в приложениях на других языках.
Что не так с float
?
Тип float
в языке PHP, как и его “родственник” тип double
, вовсе не предназначен для точного представления десятичных дробей. Всё что мы записали в float
, хранится в приближенном значении, с некоторой погрешностью.
Отсюда появляются такие интересные особенности вычислений с float
:
echo (0.1 + 0.2 == 0.3) ? 'true' : 'false';
// Выведет "false"
Всё это происходит из-за особенности представления числа с плавающей точкой в памяти.
Подробнее можно почитать в официальной документации PHP: https://www.php.net/float
Для денежных расчётов вычисления с погрешностями не годятся. Используя float
, мы рано или поздно нарвёмся на баги.
Для денег нам нужна точность!
Для чего тогда нужен float
?
float
можно использовать для тех значений, где абсолютная точность не требуется. Например, мы можем снимать температуру с датчика, записать значения в float
и нарисовать график температуры за день.
Или мы вычисляем какое-то соотношение, например “на сколько процентов увеличилось количество посетителей сайта по сравнению с предыдущим месяцем”. float
предоставит нам достаточную точность, к тому же мы скорее всего будем выводить округлённое значение, и не заметим погрешности.
Что использовать вместо float
?
Увы, в языке PHP до сих пор нет встроенного типа decimal
, который представлял бы точные значения десятичных дробей. Хорошо, что такой тип есть хотя бы в БД (MySQL, PostgreSQL), поэтому в БД мы можем объявить поле DECIMAL. Но что делать в самом PHP?
Те, кто уже столкнулся с проблемами float
в вычислениях с деньгами, решают эту проблему разными способами.
Три самых популярных решения, которые я встречал на проектах PHP:
- Целые числа + деноминатор
- Строки +
bcmath
- Объектные обёртки
Есть и ещё один вариант. Некоторые разработчики принимают решение остаться на float
и выкручиваться с помощью округлений, но я этот способ не приветствую.
Рассмотрим решения подробнее.
Решение 1. Целые числа
Способ, привлекательный своей простотой.
- Вместо
float
мы используем целочисленные значения - Для того, чтобы отображать дробную часть, вводим деноминатор — число, на которое делим хранимое целочисленное значение
Пример
В системе мы оперируем рублями, с точностью до копейки. Значит, базовой денежной единицей будет являться копейка, и хранить значения мы будем в копейках, оперируя целыми числами. Сумма “500 рублей” будет представлена числом 50 000, так как 500 рублей = 50 000 копеек. Деноминатор в такой системе равняется 100, так как в 1 рубле 100 копеек.
Получив сумму в копейках, так и записываем в копейках. Получив сумму в рублях, умножаем на деноминатор 100 и записываем. При отображении, чтобы отделить дробную часть, делим на деноминатор.
Например, так:
$sum = 50045;
$denominator = 100;
echo sprintf('%.2f', $sum / $denominator);
// Выведет "500.45"
Плюсы
- Легко освоить и реализовать
Минусы
- Высока вероятность запутаться и лишний раз поделить или умножить, тем самым зачислить или списать лишние деньги
Решение 2. Строки
Это решение встречается реже, но является более надёжным, на мой взгляд, чем целые числа и деноминатор.
- Храним значение в строковой переменной
- При вычислениях используем модуль
bcmath
(или пишем свою реализацию, что не рекомендуется)
Пример
$cost = '200.51'; // 200 рублей 51 копейка
$discount = '0.3'; // Скидка 30%
// bcmul - умножение десятичных дробей
$total = bcmul($cost, $discount, 2); // 2 - количество цифр после запятой, требуемая точность
echo $total;
// Выведет "60.15"
Плюсы
- Надёжно
Минусы
- Сложнее в понимании
- Требуется подключить расширение PHP
bcmath
Решение 3. Объекты
Для PHP есть популярные библиотеки для денежных расчётов, их можно использовать как замену float
. Основной недостаток этих библиотек я вижу в том, что они умеют слишком много и из-за этого излишне сложны в подключении и использовании.
Одна из самых популярных библиотек сейчас — Brick\Money.
Суть решения
- При получении суммы создаём объект-значение, внутри которого хранится сумма
- Вычисления производим либо методами библиотеки либо методами объекта
- При выводе форматируем методами библиотеки или объекта.
Пример с Brick/Money
$cost = Money::of('200.51', 'RUB'); // 200 рублей 51 копейка
$discount = '0.3'; // Скидка 30%
$total = $cost->multipliedBy($discount);
echo $total->getAmount();
// Выведет "60.15"
Плюсы
- Надёжно
- Используем готовое решение, не тратим время на написание своего
Минусы
- Оверхед на изучение библиотеки
- Зависимость от библиотеки
Заключение
Какой бы способ мы ни выбрали, отказавшись от float
в расчётах с деньгами мы повышаем надёжность нашей системы.
Достаточно ли это веская причина чтобы отказаться от float
?
Решать вам.