При работе с числами с фиксированной запятой вы должны быть очень осторожны — особенно если вы разрабатываете с 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 могут быть не лучшим выбором для приложений с точной математикой, но если вы выбрали этот технологический стек, полезно знать, что есть способ правильно с ним справиться.