PHP四舍五入误差

12

我在我的Linux服务器上使用PHP 5.2.13。当我尝试四舍五入数字时,出现了奇怪的错误。这是我的测试案例:

<?php
echo "        " . round(1.505, 2) . "\n";
echo "       " . round(11.505, 2) . "\n";
echo "      " . round(111.505, 2) . "\n";
echo "     " . round(1111.505, 2) . "\n";
echo "    " . round(11111.505, 2) . "\n";
echo "   " . round(111111.505, 2) . "\n";
echo "  " . round(1111111.505, 2) . "\n";
echo " " . round(11111111.505, 2) . "\n";
echo "" . round(111111111.505, 2) . "\n";

这是结果:

        1.51
       11.51
      111.51
     1111.51
    11111.51
   111111.51
  1111111.5
 11111111.51
111111111.51

有人知道是什么原因导致这个问题吗? 我无法更新PHP,因为它是共享服务器。


1
似乎是 PHP 5.2 中的一个 bug,因为我可以在 PHP 5.2.11 上重现这个问题,但在任何 PHP 5.3 版本中都没有。 - Björn
@sinaryLV sprintf 生成:1111111.50 - PiotrL
1
刚刚使用最新的PHP 5.2 (5.2.17) 复现了这个问题。但是在 5.3 版本中无法复现。 - binaryLV
4
这很可能只是一种表现错误。有问题的值在内部可能仅存在为1111111.5049999999。浮点数本质上是不精确的。而PHP5.3可能只是具有更多启发式方法。 - mario
1
PHP回复了该Bug报告,"这个问题只影响不再受支持的PHP 5.2版本"。事实上,当5.2.16版本发布时,就写明了5.2.16版本标志着结束了对PHP 5.2版本的支持。 - binaryLV
显示剩余6条评论
6个回答

13

这是因为数字1111111.505在浮点数表示法中无法精确表示,最接近的值是1111111.5049999999。所以代码中的数字被转换成1111111.50499999999,然后进行四舍五入,结果为1111111.5。浮点数存在问题,即它们无法准确表示许多即使看似简单的小数。例如,数字0.1无法在二进制浮点数中准确表示。在Python中,如果键入0.1,则返回0.10000000000001,加上或减去几个零。这就是为什么一些语言(如.Net)提供“Decimal”数据类型的原因,该数据类型能够表示一定范围和小数位数内的所有十进制值。 decimal数据类型的缺点是速度较慢,每个数字需要更多的存储空间。


1
你提到了Python,但是对于PHP有没有其他方法呢?是否有PHP Decimal类? - Wouter
PHP 的最佳选择是将十进制数表示为字符串,并使用 BCMath 扩展。 - dRamentol

8
如果仍有人遇到浮点数减法引起错误或奇怪值的类似问题,并希望更详细地解释此问题,那么我想解释一下。罪魁祸首是浮点数。为了说明问题,我将用一个简单的例子来解释,您可以从中推断出为什么会影响舍入等操作。
这与PHP没有直接关系,也不是一个bug。然而,每个程序员都应该意识到这个问题。
这个问题甚至在二十年前夺去了许多人的生命。
1991年2月25日,在沙特阿拉伯的达兰,MIM-104“爱国者”导弹电池在浮点数计算中出现问题,未能拦截一枚来袭的飞毛腿导弹,导致美国陆军第14物资供应队的28名士兵死亡。
但为什么会发生这种情况呢?
原因是浮点值表示有限精度。因此,一个值在任何处理后可能不具有相同的字符串表示形式。这还包括在脚本中编写浮点值并直接打印它而没有进行任何数学运算。
只是一个简单的例子:
$a = '36';
$b = '-35.99';
echo ($a + $b);

你可能期望它会打印0.01,对吧?但实际上它会输出一个非常奇怪的答案,如0.009999999999998。
与其他数字一样,浮点数或双精度浮点数在内存中以一串0和1的字符串形式存储。浮点数与整数不同之处在于,当我们想要查看它们时,需要如何解释这些0和1。有许多标准规定它们该如何存储。
浮点数通常被打包成一个计算机数据,从左到右依次为符号位、指数字段和有效数字或尾数。
由于二进制无法表示十进制数字,所以缺乏足够的空间。因此,您无法准确地表示1/3,因为它是0.3333333...,对吧?为什么我们不能将0.01表示为二进制浮点数呢?原因是相同的。1/100是0.00000010100011110101110000.....,其中重复出现10100011110101110000。
如果将0.01保留在简化和系统截断的01000111101011100001010的二进制形式中,并且在翻译回十进制时,它会像0.0099999...一样读取(取决于系统,64位计算机将比32位计算机提供更好的精度)。在这种情况下,操作系统决定是按照它看到的方式打印还是如何使其更具可读性。因此,它取决于机器如何表示它。但可以通过不同的方法在语言级别保护它。
如果您格式化结果,echo number_format(0.009999999999998, 2); 它将打印出0.01。
这是因为在这种情况下,您指示应该如何阅读它以及您需要的精度。
参考资料:1,2,3,4,5

据我理解,导弹失误与浮点精度无关,而是与导弹的内部时钟有关。https://en.wikipedia.org/wiki/MIM-104_Patriot - simonthesorcerer
1
深入了解内部时钟是如何工作的。这个例子在大学里用它的原始源代码进行说明。 “由于 Patriot 计算机执行计算的方式以及其寄存器仅有 24 位长,从整数到实数的时间转换不能比 24 位更精确。” https://www.cs.drexel.edu/~introcs/Fa10/notes/07.1_FloatingPoint/Patriot.html?CurrentSlide=10 - Selay
1
计算机的寄存器只有24位长。这和时间延迟的一致性表明,错误是由于在二进制基数下使用了固定点24位表示的0.1所导致的。0.1的二进制表示是非终止的;在小数点后的前23个二进制数字中,该值为0.1!(1-2-20)。在获取以秒为单位的浮点时间值时使用0.1!(1-2-20)会导致所有时间减少0.0001%。这个问题在大多数大学的软件测试课程中得到广泛研究。 - Selay
1
如果你更喜欢维基百科作为一个可靠的来源,从你提供的链接中我可以看出来,你可以在同一“可靠来源”下的浮点数讨论中看到这个问题:https://en.wikipedia.org/wiki/Floating_point - Selay
嗨,谢谢澄清。我不喜欢维基百科——只是当我在谷歌搜索导弹的名称和“沙特阿拉伯”时,它是第一个出现的。并没有冒犯的意思。 - simonthesorcerer

3

我觉得你遇到了一个由使用浮点数引起的精度问题。浮点数不能保证完全准确,你可能会发现这个问题在一个系统上出现,但在另一个系统上却没有出现。

如果你真的关心任意浮点数的精度,请使用bcmathgmp(如果有的话),但如果使用bcmath,则需要创建一个bcround()函数。我找到的唯一一个实际有效的函数是发布在php.net bcscale页面的评论中:

function bcround($number, $scale=0) {
        $fix = "5";
        for ($i=0;$i<$scale;$i++) $fix="0$fix";
        $number = bcadd($number, "0.$fix", $scale+1);
        return    bcdiv($number, "1.0",    $scale);
}

我可以确认这一点。在JS中也会发生同样的事情。边界出现在小数点上方16383以上,作为整数时是二进制的11111111111111,以及32767以下,即二进制的111111111111111。比较:(32767.505).toFixed(2)("32767.51")与(32768.505).toFixed(2)("32768.50")。如果您需要保持2位精度并且不能使用上述库,请考虑将所有内容乘以100(用便士而不是美元进行计算)。 - Andrew

3
您需要升级软件版本,而不是PHP版本。

echo " " . round(1.505, 2) . "\n";

您似乎认为您正在要求PHP返回1.505的四舍五入值 - 但实际上它必须返回1.505的二进制版本的四舍五入版本的十进制版本。
尝试在这里输入数字并查看二进制表示。

1

@Shabbyrobe:你的函数有问题:尝试将这个数字舍入为2位小数:44069.3445

应该是44069.35,但默认的php round()和你的函数返回44069.34

php.net成员提供的有效代码:

function mround($number, $precision=0) {

$precision = ($precision == 0 ? 1 : $precision);   
$pow = pow(10, $precision);

$ceil = ceil($number * $pow)/$pow;
$floor = floor($number * $pow)/$pow;

$pow = pow(10, $precision+1);

$diffCeil     = $pow*($ceil-$number);
$diffFloor     = $pow*($number-$floor)+($number < 0 ? -1 : 1);

if($diffCeil >= $diffFloor) return $floor;
else return $ceil;
}

1

Shabbyrobe的答案中的代码无法处理负数。 以下是对该代码的改进,可以处理负数和默认比例:

function bcround($number, $scale = null) {
    if (is_null($scale)) {
        $scale = bcscale();
    }
    $fix = ($number < 0) ? '-' : '';
    $fix .= '0.' . str_repeat('0', $scale) . '5';
    $number = bcadd($number, $fix, $scale + 1);
    return bcdiv($number, "1.0", $scale);
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接