Небольшой тест перед чтением:
<?php var_dump(0.1 + 0.2 === 0.3);
Этот код возвращает false. Теперь, как разработчики, которые с гордостью знают стандарты плавающей запятой IEEE, многие из вас могут сделать глоток кофе и сказать: «Погодите, это уже не новость. Все мы знаем этот классический пример, который предупреждает поколения начинающих разработчиков о том, что прямые вычисления с числами с плавающей запятой могут вызвать баги».
Тем не менее, давайте посмотрим на следующий пример (64-бит):
<?php var_dump((9223372036854775808 - 1) === 9223372036854775807);
Что ж, очевидно, что это вычисление с целыми числами. На него точно не влияет никакое странное поведение чисел с плавающей запятой, и оно должно возвращать true. Нам ведь не нужно здесь использовать какие-либо математические расширения повышенной точности (BCMath, GMP), верно?
Верно?
Тип float в PHP зависит от платформы, но обычно он использует формат IEEE 754 binary64, который включает знак, экспоненту и 53 бита бинарной точности. В руководстве PHP практический результат описывается примерно как 14 десятичных знаков точности с максимальной относительной погрешностью округления порядка 1.11e-16 .
Важное слово здесь — бинарный. Число с плавающей запятой представляет собой конечную сумму степеней двойки. Дроби вроде 0.5 ( 1/2 ) и 0.125 ( 1/8 ) имеют конечное бинарное представление, поэтому их можно сохранить точно. 0.1 ( 1/10 ) — нельзя: ее бинарное представление бесконечно повторяется, точно так же, как 1/3 бесконечно повторяется в десятичной системе.
Поэтому PHP сохраняет ближайшее представимое бинарное значение. Мы можем сделать эти приближения видимыми, если выведем достаточное количество знаков:
<?php
printf("%.17g\n", 0.1); // 0.10000000000000001
printf("%.17g\n", 0.2); // 0.20000000000000001
printf("%.17g\n", 0.1 + 0.2); // 0.30000000000000004
Работа с числами с плавающей запятой
Обе стороны исходного сравнения являются числами с плавающей запятой, но они приводят к разным близлежащим бинарным значениям:
<?php $sum = 0.1 + 0.2; // float(0.30000000000000004) var_dump($sum === 0.3); // false var_dump(abs($sum - 0.3) < 1e-12); // true
Работа с большими целыми числами
Целые числа в PHP являются знаковыми и зависят от платформы. Максимальное значение целого числа задается константой PHP_INT_MAX .
В 64-битной сборке их диапазон обычно заканчивается на 9223372036854775807 . Согласно правилам переполнения целых чисел в руководстве, литерал или операция за пределами диапазона целых чисел превращается в число с плавающей запятой:
<?php var_dump(PHP_INT_MAX); // int(9223372036854775807) var_dump(PHP_INT_MAX + 1); // float(9.223372036854776E+18)
Бинго! Число binary64 с плавающей запятой имеет достаточно диапазона, чтобы вместить число такого порядка, но недостаточно точности, чтобы различать каждое целое число вокруг него. Итак, вернемся к примеру в начале.
<?php var_dump(((PHP_INT_MAX + 1) - 1) === PHP_INT_MAX); // false var_dump((9223372036854775808 - 1) === 9223372036854775807); // false (64-bit)
Как только целое число переполняется до числа с плавающей запятой, обратное преобразование не может восстановить потерянные младшие разряды.
Ладно-ладно-ладно, числа с плавающей запятой — это странно, могут сказать многие. Но есть ли в PHP способ выполнять такие вычисления правильно? Конечно, да!
Безопасная работа со встроенными числами с плавающей запятой
Числа с плавающей запятой компактны, быстры и отлично подходят для приближенных величин. Использовать их правильно означает принять тот факт, что их последние цифры не являются точными. Вот несколько рекомендаций по правильной работе со встроенными числами с плавающей запятой в PHP:
Не приводите слепо числа с плавающей запятой к целым числам!
Снова время викторины! Что выведет следующий код?
<?php $value = 0.58 * 100; echo $value, "\n"; var_dump(intval($value)); var_dump((int) $value);
Значение на выходе:
58 int(57) int(57)
Интересно, правда? intval() и приведение к (int) не округляют до ближайшего целого числа. Они отбрасывают дробную часть путем округления в сторону нуля. Если это конкретное вычисление должно давать ближайшее целое число, предварительный вызов round() дает ожидаемый результат:
<?php $value = 0.58 * 100; $result = (int) round($value); var_dump($result); // int(58)
Так что будьте осторожны при приведении чисел с плавающей запятой к целым.
Кто-то может спросить: погодите, почему в этом случае echo $value, "\n"; возвращает 58 ? Это подводит нас ко второму вопросу:
Настройка INI precision НЕ исправляет вычисления
<?php
ini_set('precision', '14'); // 14 is the default value.
$value = 0.58 * 100;
echo $value, "\n"; // 58
var_dump(intval($value)); // int(57)
Почему одно и то же значение, отображаемое как 58 с помощью echo, может быть преобразовано в 57 здесь? Вывод значения с помощью echo использует настроенный параметр precision , который «скрывает» последние цифры. Преобразование целых чисел, очевидно, не использует этот округленный текст. Оно считывает сохраненное число с плавающей запятой напрямую, и сохраненное значение чуть меньше, чем 58 .
Директива php.ini precision лишь управляет тем, сколько цифр используется при преобразовании числа с плавающей запятой в строку. Она не изменяет значение в памяти или арифметику, выполняемую процессором:
<?php
$sum = 0.1 + 0.2;
ini_set('precision', '17');
echo $sum, "\n"; // 0.30000000000000004
ini_set('precision', '14');
echo $sum, "\n"; // 0.3
var_dump($sum === 0.3); // bool(false)
Обе инструкции echo считывают одно и то же число с плавающей запятой. Одно представление скрывает последние цифры, а другое их раскрывает, но ни одно из них не меняет результат строгого сравнения.
В PHP также есть отдельная директива serialize_precision . Несмотря на название, она управляет текстовым представлением чисел с плавающей запятой, создаваемых не только с помощью serialize() , но и такими функциями, как json_encode() и var_dump() . Она не влияет на арифметику, которая их породила. Они не являются математическими функциями.
Сравнение чисел с плавающей запятой на равенство на основе допуска
Когда приближенные значения уместны, руководство PHP рекомендует сравнивать их с использованием допустимой границы погрешности вместо прямого равенства. Простая проверка, показанная ранее, работает, когда все значения имеют схожий и известный масштаб:
<?php $actual = 0.1 + 0.2; $expected = 0.3; var_dump(abs($actual - $expected) < 1e-12); // bool(true)
Допуск должен определяться предметной областью. 1e-12 подходит для этой небольшой демоверсии, но это, конечно, не волшебное значение, которое подходит для любых вычислений.
PHP_FLOAT_EPSILON описывает расстояние между представимыми числами с плавающей запятой около 1.0 ; это не обязательно правильный допуск для любого порядка величин или любого приложения. Ознакомьтесь с рекомендациями руководства по сравнению чисел с плавающей запятой для понимания базового подхода с эпсилоном.
Точная арифметика с фиксированной точностью с целыми числами
Когда значение имеет фиксированную наименьшую единицу измерения, хранение этой единицы в виде целого числа полностью исключает двоичные дроби. Например, деньги часто хранятся в центах:
<?php $unitPriceInCents = 58; $quantity = 3; $totalInCents = $unitPriceInCents * $quantity; var_dump($totalInCents); // int(174)
Преобразование в мелкие единицы также должно быть точным. Не принимайте 0.58 в качестве числа с плавающей запятой и не предполагайте, что (int) ($value * 100) безопасен; это тот же баг преобразования, который мы видели выше. Разбирайте проверенную десятичную строку или используйте десятичную арифметику на границе ввода.
Встроенные целые числа по-прежнему имеют ограничение PHP_INT_MAX . Если масштабированные значения могут превысить его, используйте представление произвольной точности с самого начала.
Могут помочь основные расширения!
Черт. Я не хочу запоминать эти правила и переводить все свои числа с плавающей запятой в целые числа, меняя единицы измерения для точности. Что ж, на помощь приходят основные расширения, которые могут вам помочь.
Работаете с числами с плавающей запятой? Используйте BCMath
BCMath выполняет десятичную арифметику произвольной точности. Его традиционные функции принимают и возвращают строки, поэтому десятичный ввод сначала не проходит через двоичное число с плавающей запятой:
<?php
$sum = bcadd('0.1', '0.2', 1);
$scaled = bcmul('0.58', '100', 0);
var_dump($sum); // string(3) "0.3"
var_dump($scaled); // string(2) "58"
PHP 8.4 и более поздние версии также предоставляют неизменяемый объект BcMath\Number с поддержкой обычных арифметических операторов:
<?php
use BcMath\Number;
$sum = new Number('0.1') + new Number('0.2');
echo $sum, "\n"; // 0.3
Кавычки важны. Формируйте вычисления из десятичных строк, таких как '0.1' , но НЕ из чисел с плавающей запятой, которые уже могли потерять точность. Последующее преобразование приближенного числа с плавающей запятой в значение BCMath не может восстановить исходный точный десятичный ввод.
BCMath отлично подходит для цен, балансов, ставок и других значений, где десятичные знаки имеют точное значение.
Работаете с большими целыми числами? Используйте GMP
Расширение GMP работает с целыми числами произвольной длины. Оно не включено по умолчанию и требует внешней библиотеки GMP. Оно может вычислить пример с большим целым числом из начала статьи без переполнения до числа с плавающей запятой:
<?php
$number = gmp_init('9223372036854775808', 10);
$result = gmp_sub($number, '1');
echo gmp_strval($result), "\n"; // 9223372036854775807
Аналогично, большие входные данные должны быть строками. Запись 9223372036854775808 в виде числового литерала PHP сначала позволит PHP преобразовать его в число с плавающей запятой до того, как его получит GMP, что уже слишком поздно.
GMP представляет целые числа, а не десятичные дроби, поэтому он не может напрямую хранить 0.1 . Он может обрабатывать значения с фиксированной точностью, если приложение представляет каждую сумму как целое число мелких единиц. BCMath обычно понятнее, когда сама десятичная точность является частью данных.
Как правильно вычислить 0.1 плюс 0.2 в PHP?
Итак, как PHP должен вычислять 0.1 + 0.2 ? Если значения являются приближенными, сложение встроенных чисел с плавающей запятой уже выполняет ожидаемое бинарное вычисление. Сравните результат с соответствующим допуском и отформатируйте его для отображения. Если ответом должна быть точная десятичная дробь 0.3 , начните с десятичного представления:
<?php
echo bcadd('0.1', '0.2', 1); // 0.3
Не существует переключателя php.ini , который превращает двоичное число с плавающей запятой в точную десятичную дробь. Корректность достигается за счет выбора представления до начала вычислений.
Постоянное развитие
И BCMath, и GMP находятся в активной разработке. Например, в PHP 8.6 добавлены две новые функции в GMP: gmp_prev_prime и gmp_powm_sec . Это полезные функции! Язык PHP быстро развивается, чтобы сделать жизнь каждого PHP-разработчика проще.
Язык PHP живет вечно, благодаря всей страсти, труду и чистой любви, которые вкладывают в него наши дорогие контрибьюторы. Спасибо за чтение и использование PHP!
Комментарии (0)
Пока нет комментариев — будьте первым.