Небольшой тест перед чтением:

<?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!