Граждане! Храните деньги в сберегательной кассе!

— Жорж Милославский

В рамках этой статьи я рассматриваю проблему с некорректным использованием 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:

  1. Целые числа + деноминатор
  2. Строки + bcmath
  3. Объектные обёртки

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

Рассмотрим решения подробнее.

Решение 1. Целые числа

Способ, привлекательный своей простотой.

  1. Вместо float мы используем целочисленные значения
  2. Для того, чтобы отображать дробную часть, вводим деноминатор — число, на которое делим хранимое целочисленное значение

Пример

В системе мы оперируем рублями, с точностью до копейки. Значит, базовой денежной единицей будет являться копейка, и хранить значения мы будем в копейках, оперируя целыми числами. Сумма “500 рублей” будет представлена числом 50 000, так как 500 рублей = 50 000 копеек. Деноминатор в такой системе равняется 100, так как в 1 рубле 100 копеек.

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

Например, так:

$sum = 50045;
$denominator = 100;
echo sprintf('%.2f', $sum / $denominator);

// Выведет "500.45"

Плюсы

  • Легко освоить и реализовать

Минусы

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

Решение 2. Строки

Это решение встречается реже, но является более надёжным, на мой взгляд, чем целые числа и деноминатор.

  1. Храним значение в строковой переменной
  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.

Суть решения

  1. При получении суммы создаём объект-значение, внутри которого хранится сумма
  2. Вычисления производим либо методами библиотеки либо методами объекта
  3. При выводе форматируем методами библиотеки или объекта.

Пример с 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?

Решать вам.