
阅读前先来做个小测试:
var_dump(0.1 + 0.2 === 0.3); |
|---|
这段代码会返回 false。作为熟悉IEEE浮点数规则的开发者,很多人可能会抿一口咖啡说:嗨,这早就不是什么新鲜事了。我们都知道这个经典案例,它提醒着一代又一代初级开发者:直接用浮点数计算可能会引发bug。
不过,我们来看下面这个例子(64位环境下):
var_dump((9223372036854775808 - 1) === 9223372036854775807); |
|---|
很明显这是整数运算,肯定不会受那些奇怪的浮点数特性影响,结果应该是true对吧。我们总不至于在这里也要用高精度数学扩展(BCMath、GMP)吧? 对吧?
PHP的float类型依赖于平台,但通常采用IEEE 754 binary64(双精度)格式,包含符号位、指数位和53位二进制精度。PHP手册中说明,其实际精度约为14位十进制数,最大相对舍入误差约为1.11e-16。
这里的关键词是二进制。浮点数表示的是2的幂次的有限和。像0.5(1/2)和0.125(1/8)这样的小数,其二进制表示是有限的,因此可以被精确存储。但0.1(1/10)做不到:它的二进制表示是无限循环的,就像十进制里1/3永远循环一样。
因此PHP只会存储最接近的可表示二进制值。我们可以通过打印足够多的小数位,来直观看到这些近似值:
printf("%.17g\n", 0.1); // 输出 0.10000000000000001printf("%.17g\n", 0.2); // 输出 0.20000000000000001printf("%.17g\n", 0.1 + 0.2); // 输出 0.30000000000000004 |
|---|
最初的比较中,等号两边都是浮点数,但它们对应了两个不同的邻近二进制值:
$sum = 0.1 + 0.2; // float(0.30000000000000004)var_dump($sum === 0.3); // bool(false)var_dump(abs($sum - 0.3) < 1e-12); // bool(true) |
|---|
PHP的整数是有符号的,且依赖于平台。整数的最大值由PHP_INT_MAX定义。
在64位编译版本中,整数范围的上限通常是9223372036854775807。根据手册中的整数溢出规则,当字面量或运算结果超出整数范围时,就会转为浮点数:
var_dump(PHP_INT_MAX); // int(9223372036854775807)var_dump(PHP_INT_MAX + 1); // float(9.223372036854776E+18) |
|---|
问题就在这里!binary64浮点数的数值范围足以容纳这么大的数,但精度不足以区分这个量级下的每一个整数。我们回到开头的例子:
var_dump(((PHP_INT_MAX + 1) - 1) === PHP_INT_MAX); // bool(false)var_dump((9223372036854775808 - 1) === 9223372036854775807); // bool(false)(64位环境) |
|---|
一旦整数溢出转为浮点数,再转换回整数也无法找回丢失的低位数字。
好吧,浮点数确实很反直觉,很多人可能会这么说。但PHP里有没有办法让这些计算得到正确结果?当然有!
浮点数占用空间小、运算速度快,非常适合处理近似的数值。正确使用它们的前提,是接受最后几位数字并非精确值这一事实。以下是一些正确使用PHP原生浮点数的建议:
又到测试时间!下面这段代码会输出什么?
$value = 0.58 * 100; echo $value, "\n"; var_dump(intval($value));var_dump((int) $value); |
|---|
输出结果是:
58int(57)int(57) |
|---|
是不是很有意思?intval()和(int)强制转换都不会四舍五入到最近的整数,而是直接舍去小数部分,向零取整。如果这个计算需要得到最接近的整数值,先调用round()再转换就能得到预期结果:
$value = 0.58 * 100;$result = (int) round($value); var_dump($result); // int(58) |
|---|
所以将浮点数转为整数时一定要谨慎。
有人可能会问:等等,那为什么echo $value, "\n";会输出58?这就引出了第二个问题:
ini_set('precision', '14'); // 14是默认值$value = 0.58 * 100; echo $value, "\n"; // 输出 58var_dump(intval($value)); // int(57) |
|---|
为什么同一个值,用echo打印显示是58,转换为整数却变成了57?因为echo打印浮点数时,会使用配置的precision参数,这个参数会“隐藏”末尾的数位。而整数转换显然不会用这个四舍五入后的字符串,它直接读取内存中存储的浮点数本身——而存储的值其实略小于58。
php.ini中的precision指令,只控制浮点数转换为字符串时显示的位数,它不会改变内存中的数值,也不会改变处理器执行的算术运算:
$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手册建议使用可接受的误差范围来比较浮点数,而不是直接判断相等。前面用到的简单校验方式,在所有数值量级相近且已知的场景下是有效的:
$actual = 0.1 + 0.2;$expected = 0.3; var_dump(abs($actual - $expected) < 1e-12); // bool(true) |
|---|
容差值需要根据业务场景来定。1e-12适合这个小示例,但它绝不是适用于所有计算的万能值。
PHP_FLOAT_EPSILON描述的是1.0附近可表示浮点数的最小间隔;它并不是自动适用于所有数量级、所有业务场景的正确容差。更多关于epsilon方法的基础说明,可以参考手册中浮点数比较的相关指引。
如果数值有固定的最小单位,以这个最小单位为基准用整数存储,可以完全避免二进制小数的问题。例如,金额通常以分为单位存储:
$unitPriceInCents = 58; // 单价(分)$quantity = 3; // 数量 $totalInCents = $unitPriceInCents * $quantity; var_dump($totalInCents); // int(174) |
|---|
转换为最小单位的过程也必须是精确的。不要先接收0.58这样的浮点数,再假设(int) ($value * 100)是安全的——这和我们上面看到的转换bug是同一个问题。应该在输入边界处解析经过校验的十进制字符串,或者使用十进制运算。
原生整数仍然有PHP_INT_MAX的上限。如果换算后的值可能超出这个范围,从一开始就应该使用任意精度的表示方式。
害,我可不想记住这些规则,也不想为了精度把所有浮点数都转成整数、改单位。没关系,核心扩展这就来帮你。
BCMath执行任意精度的十进制算术运算。它的传统函数接收和返回的都是字符串,因此十进制输入不会先经过二进制浮点数转换:
$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对象,支持普通的算术运算符:
use BcMath\Number; $sum = new Number('0.1') + new Number('0.2'); echo $sum, "\n"; // 输出 0.3 |
|---|
引号很重要。要从'0.1'这样的十进制字符串构建运算,而不是用可能已经丢失精度的浮点数。事后把一个近似的浮点数转为BCMath值,是无法还原最初精确的十进制输入的。
BCMath非常适合价格、余额、利率等小数位有明确含义的数值场景。
GMP扩展用于处理任意长度的整数。它默认不开启,需要依赖外部GMP库。它可以无溢出地计算开头的大整数例子,不会转为浮点数:
$number = gmp_init('9223372036854775808', 10);$result = gmp_sub($number, '1'); echo gmp_strval($result), "\n"; // 输出 9223372036854775807 |
|---|
同样,大的输入值必须用字符串形式。如果直接写9223372036854775808这个PHP数字字面量,PHP会先把它转为浮点数,再传给GMP——这时精度已经丢失了。
GMP表示的是整数而非十进制小数,因此不能直接存储0.1。如果业务中用最小单位的整数来表示所有数值,它也可以处理固定小数位的场景。但当小数位数本身就是数据的一部分时,BCMath通常更清晰易用。
那么,PHP应该怎样计算0.1 + 0.2呢?如果数值本身就是近似值,原生浮点数加法已经在执行预期的二进制运算了。用合适的容差比较结果,再格式化输出即可。如果必须得到精确的十进制结果0.3,那就要从十进制表示开始计算:
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!