Не используйте числа с плавающей точкой для денежных расчётов

Многие учебники по программированию об этом умалчивают: числа с плавающей точкой (типы float, double в Си, real в Паскале) нельзя использовать для расчёта стоимости товара, зарплаты сотрудника и т.п. из-за погрешности представления в двоичной системе. Как вы думаете, что выдаст этот код?

$x = 7 / 25 * 25;
echo $x . ' ' . ($x == 7 ? 'равно семи' : 'не равно семи');


На PHP 5.3 результат будет таким:

7 не равно семи


Почему так происходит


Числа с плавающей точкой представляются в компьютере в виде двоичных дробей. Те дроби, у которых в знаменателе — степень двойки, можно представить точно. Например, 50 копеек = 0,5 = 1/2 или 75 копеек = 0,75 = 3/4.

А те, у которых — не степень двойки, невозможно. Например, 10 копеек = 0,10 будет представлено как 7205759403792794 / 256 (см. математические выкладки). А это число равно не 0,1, а чуть больше:

printf("%.56f", 0.1);
0.1000000000000000055511151231257827021182


Если выполнять много операций, то погрешность накапливается:

$sum = 0;
for($i = 0; $i < 1000000; $i++)
    $sum += 0.1;
echo $sum;


Этот код выдаёт «100000.000001».

Также возникают ошибки при сравнении чисел (пример выше) и преобразовании в целое (integer).

Что делать?


В некоторых языках есть специальные типы данных, в которых дроби умножаются на степень 10. Проще говоря, сохраняется число копеек, например, целое число 1510 вместо дроби 15,10. В Visual Basic такой тип называется Decimal.

В PHP есть функции BC Math. К сожалению, у них невысокая производительность (из-за хранения чисел в строках) и громоздкий синтаксис:

bcscale(2);
$total = bcadd($total, bcmul($price, $count));


вместо

$total += $price * $count;


В идеале, PHP мог бы ввести десятичный тип или даже использовать его по умолчанию, так как web-скрипты чаще всего выполняют денежные расчёты. Пока такого типа нет, приходится мириться с неудобствами функций BC Math.

Если вы ограничиваетесь только сложением, вычитанием и умножением на количество, то ничего сложного в их использовании нет. При вычислении скидок и налогов, делении на число дней и т.п. нужно писать свой код для округления, так как BC Math всегда округляет неполную копейку в меньшую сторону:

bcscale(2);
print(bcdiv('2', '3') . "<br>");
// Выводит 0.66, а не 0.67

print(bcmul('5.45', '0.13'));
// Выводит 0.70 (точный результат 0.7085, нужно округлять до 0.71)


Ещё один вариант (рекомендуется пользователями Stack Overflow): хранить все суммы в целых числах (integer) в копейках. Опять же, нужно следить за округлением при умножении на дроби (проценты, скидки, налоги) и делении. Этот способ я использовал, когда делал форму заказа по прайс-листу для одной из новокузнецких контор.

Ну и конечно, если где-то всё-таки используется плавающая точка, то не стóит «кричать» об этом, выводя излишнее количество знаков после запятой:

Абонентская плата по тарифу Старт: -74.1935483871

:)
  • +1
  • 19 сентября 2010, 10:09
  • peter

Комментарии (3)

RSS свернуть / развернуть
+
0
Можно было бы и продолжить статейку, рассказав об различных типах округления.
avatar

blognya

  • 19 сентября 2010, 11:48
+
0
Думал об этом, но AFAIK в России не используется bankers' rounding. Налоговый кодекс требует округлять в режиме PHP_ROUND_HALF_UP; это же правило обычно применяют в повседневных расчётах (как учат в школе: 4.5 округляем до 5). Вот и весь сказ :)
avatar

peter

  • 19 сентября 2010, 17:40
+
0
ну вот видишь, рассказал же)
avatar

blognya

  • 19 сентября 2010, 19:36

Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.