Статьи

Fixed Point Math в PHP с BCMath, случаи потери точности

При работе с числами с фиксированной запятой вы должны быть очень осторожны — особенно если вы разрабатываете с PHP и MySQL. В этой статье описываются препятствия и тонкости работы с расширением PHP BCMath, обработкой выражений с фиксированной точкой MySQL и сохранением данных с фиксированной точкой от PHP до MySQL. Несмотря на возникающие барьеры, мы пытаемся выяснить, как работать с числами с фиксированной точкой, чтобы не потерять цифру.

Проблемы с BCMath

Документация BCMath гласит:

Для математики произвольной точности PHP предлагает Бинарный калькулятор, который поддерживает числа любого размера и точности, представленные в виде строк .

Поэтому параметры функции BCMath должны быть представлены в виде строк. Передача числовых значений в bcmath может привести к неверным результатам, такой же потере точности, как и при обработке двойного значения как строки

Дело 1

 echo bcmul(776.210000, '100', 10) . PHP_EOL; echo bcmul(776.211000, '100', 10) . PHP_EOL; echo bcmul(776.210100, '100', 10) . PHP_EOL; echo bcmul(50018850776.210000, '100', 10) . PHP_EOL; echo bcmul(50018850776.211000, '100', 10) . PHP_EOL; echo bcmul(50018850776.210100, '100', 10) . PHP_EOL; 

Результаты:

 77621.00 77621.100 77621.0100 5001885077621.00 5001885077621.100 5001885077621.00 //here we can see precision loss 

Никогда не передавайте числовые значения в функции BCMath, только строковые значения, которые представляют числа . Даже если BCMath не имеет дело с плавающей точкой, он может выдавать странные результаты:

Дело 2

 echo bcmul('10', 0.0001, 10) . PHP_EOL; echo bcmul('10', 0.00001, 10) . PHP_EOL; echo 10*0.00001 . PHP_EOL; 

Результаты:

 0.0010 0 // thats really strange!!! 0.0001 

Причина этого в том, что BCMath преобразует свои аргументы в строки, и в некоторых случаях строковое представление числа имеет экспоненциальную запись.

Дело 3

 echo bcmul('10', '1e-4', 10) . PHP_EOL; //outputs 0 as well 

PHP — это слабо типизированный язык, и в некоторых случаях вы не можете строго контролировать ввод — вы хотите обрабатывать как можно больше запросов.

Например, мы можем «исправить» Case 2 и Case 3 , применив преобразование sprintf :

 $val = sprintf("%.10f", '1e-5'); echo bcmul('10', $val, 10) . PHP_EOL; // gives us 0.0001000000 

но применение того же преобразования может нарушить «правильное» поведение в случае 1 :

 $val = sprintf("%.10f", '50018850776.2100000000'); echo bcmul('10', $val, 10) . PHP_EOL; echo bcmul('10', 50018850776.2100000000, 10) . PHP_EOL; 500188507762.0999908450 //WRONG 500188507762.10 //RIGHT 

Поэтому решение sprintf не подходит для BCmath. Предполагая, что все пользовательские вводы являются строками, мы можем реализовать простой валидатор, отлавливающий все числа экспоненциальных обозначений и преобразующий их должным образом. Эта техника выполняется в php-bignumbers , поэтому мы можем безопасно передавать аргументы типа 1e-20 и 50018850776.2101 без потери точности.

 echo bcmul("50018850776.2101", '100', 10) . PHP_EOL; echo bcmul(Decimal::create("50018850776.2101"), '100', 10) . PHP_EOL; echo bcmul(Decimal::create("1e-8"), '100', 10) . PHP_EOL; echo bcmul("1e-8", '100', 10) . PHP_EOL; echo bcmul(50018850776.2101, '100', 10) . PHP_EOL; echo bcmul(Decimal::create(50018850776.2101), '100', 10) . PHP_EOL; // Result // 5001885077621.0100 // 5001885077621.0100 // 0.00000100 // 0 // 5001885077621.00 // 5001885077621.00982700 

Но последние две строки примера показывают нам, что предостережения с плавающей запятой нельзя избежать с помощью анализа ввода (что совершенно логично — мы не можем иметь дело с внутренним двойным представлением PHP).

BCMath окончательные рекомендации

Никогда не используйте числа с плавающей запятой в качестве аргументов операции с фиксированной запятой. Преобразование строк не помогает, потому что мы никак не можем управлять потерей точности.

При использовании операций расширения BCMath будьте осторожны с аргументами в экспоненциальном представлении. Функции BCMath не обрабатывают экспоненциальные аргументы (т. Е. ‘1e-8’) правильно, поэтому вы должны преобразовать их вручную. Будьте осторожны, не используйте sprintf или подобные методы преобразования, потому что это приводит к потере точности.

Вы можете использовать библиотеку php-bignumbers, которая обрабатывает входные аргументы в экспоненциальной форме и предоставляет пользователям математические функции с фиксированной запятой. Однако его производительность хуже, чем у расширения BCMath , поэтому это своего рода компромисс между надежным пакетом и производительностью.

MySQL и номера с фиксированной точкой

В MySQL номера с фиксированной точкой обрабатываются с типом столбца DECIMAL . Вы можете прочитать официальную документацию MySQL для типов данных и точных математических операций .

Самое интересное в том, как MySQL обрабатывает выражения:

Обработка числового выражения зависит от вида значений, содержащихся в выражении:

Если присутствуют какие-либо приблизительные значения, выражение является приблизительным и вычисляется с использованием арифметики с плавающей точкой.

Если приблизительные значения отсутствуют, выражение содержит только точные значения. Если какое-либо точное значение содержит дробную часть (значение после десятичной точки), выражение вычисляется с использованием точной арифметики DECIMAL и имеет точность 65 цифр. Термин «точный» зависит от того, что может быть представлено в двоичном виде. Например, 1.0 / 3.0 может быть аппроксимирован в десятичной записи как .333…, но не может быть записан как точное число, поэтому (1.0 / 3.0) * 3.0 не дает точного значения 1.0.

В противном случае выражение содержит только целочисленные значения. Выражение является точным и вычисляется с использованием целочисленной арифметики и имеет точность, аналогичную BIGINT (64 бита).

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

Вот короткий пример, который демонстрирует случаи дробной части:

 mysql> CREATE TABLE fixed_point ( -> amount NUMERIC(40,20) NOT NULL -> ) engine=InnoDB, charset=utf8; Query OK, 0 rows affected (0.02 sec) mysql> INSERT INTO fixed_point (amount) VALUES(0.2); Query OK, 1 row affected (0.00 sec) mysql> SELECT amount, amount + 0.1, amount + 1e-1, amount + '0.1' FROM fixed_point; +------------------------+------------------------+---------------------+---------------------+ | amount | amount + 0.1 | amount + 1e-1 | amount + '0.1' | +------------------------+------------------------+---------------------+---------------------+ | 0.20000000000000000000 | 0.30000000000000000000 | 0.30000000000000004 | 0.30000000000000004 | +------------------------+------------------------+---------------------+---------------------+ 1 row in set (0.00 sec) 

Это может показаться довольно простым, но давайте посмотрим, как с этим бороться в PHP.

Точность математики в PHP и MySQL

Так что теперь мы должны сохранить наши значения с фиксированной точкой из PHP в MySQL. Правильный способ — использовать подготовленные операторы и заполнители в наших запросах. Затем мы делаем привязку параметров, и все в безопасности.

 $amount_to_add = "0.01"; $stmt = $dbh->prepare("UPDATE fixed_point SET amount = amount + :amount"); $stmt->bindValue("amount", $amount_to_add); $stmt->execute(); 

Когда мы привязываем значение к заполнителю оператора, мы можем указать его тип с bindValue третьего аргумента bindValue . Возможные типы представлены константами PDO::PARAM_BOOL , PDO::PARAM_NULL , PDO::PARAM_INT , PDO::PARAM_STR , PDO::PARAM_LOB и PDO::PARAM_STMT . Таким образом, проблема в том, что расширение PHP PDO не имеет десятичного типа параметра для привязки. В результате все математические выражения в запросах обрабатываются как выражения с плавающей запятой, а не как выражения с фиксированной запятой.

 $dbh = new PDO("mysql:host=localhost;dbname=test", "root", ""); $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $sql = " CREATE TABLE IF NOT EXISTS fixed_point ( amount DECIMAL(43,20) ) "; $dbh->query($sql); $dbh->query("DELETE FROM fixed_point"); $dbh->query("INSERT INTO fixed_point VALUES(0.2)"); $amount_to_add = "0.1"; $stmt = $dbh->prepare("UPDATE fixed_point SET amount = amount + :amount"); $stmt->bindValue("amount", $amount_to_add); $stmt->execute(); $stmt = $dbh->prepare("SELECT amount FROM fixed_point"); $stmt->execute(); var_dump($stmt->fetchColumn()); //output is string(22) "0.30000000000000004000" 

Если мы хотим воспользоваться преимуществами подготовленных операторов и работать с числами с фиксированной запятой, лучший способ — выполнить все математические операции в PHP и сохранить результаты в MySQL.

 $amount_to_add = "0.1"; $stmt = $dbh->prepare("SELECT amount FROM fixed_point"); $stmt->execute(); $amount = $stmt->fetchColumn(); $new_amount = bcadd($amount, $amount_to_add, 20); $stmt = $dbh->prepare("UPDATE fixed_point SET amount=:amount"); $stmt->bindValue("amount", $new_amount); $stmt->execute(); $stmt = $dbh->prepare("SELECT amount FROM fixed_point"); $stmt->execute(); $amount_after_change = $stmt->fetchColumn(); echo $amount_after_change . PHP_EOL; 

Вывод

Мы пришли к следующим выводам:

  • Никогда не используйте числа с плавающей запятой в качестве аргументов операций с фиксированной запятой в функциях расширения BCMath PHP. Только строки.
  • Расширение BCMath не работает со строковыми числами в экспоненциальном представлении
  • MySQL поддерживает выражения с фиксированной точкой, но все операнды должны быть в десятичном формате. Если хотя бы один агрегат имеет экспоненциальный формат или строку, он рассматривается как число с плавающей запятой, а выражение оценивается как число с плавающей запятой.
  • Расширение PHP PDO не имеет Decimal типа параметра, поэтому, если вы используете подготовленные операторы и параметры привязки в выражениях SQL, которые содержат операнды с фиксированной запятой, вы не получите точных результатов.
  • Для выполнения точных математических операций в приложениях PHP + MySQL вы можете выбрать два способа. Первый — это обработка всех операций в PHP и сохранение данных в MySQL только с помощью операторов INSERT или UPDATE . В этом случае вы можете использовать подготовленные операторы и привязку параметров. Второе — это создание SQL-запросов вручную (вы все еще можете использовать подготовленные операторы, но вы должны экранировать параметры самостоятельно), поэтому все математические выражения SQL представлены в десятичном формате.

Мой личный любимый подход — первый: все математические операции в PHP. Я согласен с тем, что PHP и MySQL могут быть не лучшим выбором для приложений с точной математикой, но если вы выбрали этот технологический стек, полезно знать, что есть способ правильно с ним справиться.