PHP - 浮点数精度

96
$a = '35';
$b = '-34.99';
echo ($a + $b);

结果为0.009999999999998

这是怎么回事?我想知道为什么我的程序会报告奇怪的结果。

为什么PHP没有返回预期的0.01?


17
建议阅读有关浮点数的内容。特别是其中的“可表示数字、转换和舍入”和“精度问题”部分。如果想要了解它们的工作原理,文章的其余部分也不错,但这两个部分特别适用于您的问题... - ircmaxell
10
值得注意的是,您正在使用字符串而不是数字(尽管它们会被隐式转换,但仍然存在这种情况)。建议改用 $a = 35; $b = -34.99 - NullUserException
请查看以下链接:https://dev59.com/hmQm5IYBdhLWcg3w8iyD#27539234 和 https://dev59.com/G3RB5IYBdhLWcg3wj36c - Selay
8个回答

136
因为浮点运算 != 实数运算。由于精度不准确,一些浮点数 ab 可能会出现差异,(a+b)-b != a。这适用于使用浮点数的任何语言。
由于浮点数是具有有限精度的二进制数字,存在有限数量的可表示数字,这导致了精度问题和类似这样的意外。这里还有另一个有趣的阅读材料: 计算机科学家应该知道的浮点运算知识
回到你的问题,基本上在二进制中无法准确表示34.99或0.01(就像在十进制中1/3 = 0.3333...一样),因此只能使用近似值。为了解决这个问题,你可以:
  1. 对结果使用round($result, 2)来将其四舍五入为2位小数。
  2. 使用整数。如果是货币,比如美元,则将$35.00存储为3500,将$34.99存储为3499,然后将结果除以100。
遗憾的是,PHP没有像other languages那样的十进制数据类型。

我想补充一点,0.01也不能直接表示。这个回答应该被标记为正确的,因为它提供了解释和如何修复的方法。但是为了增加其实用性,请解释一下为什么fp != real,以及所有那些二进制和精度损失的东西。 - Andrey
8
一个学究式的注释:有一组有限的浮点数ab,其中(a+b)-b == a。它们需要都具有2为质因子,并能够用适当数量的比特表示(单精度约为7位十进制数字,双精度约为16位)。所以a=0.5b=0.25也可以(对于具有32位单精度浮点数系统始终适用)。对于不符合这些先决条件的浮点数,(a+b)-b!=a。但如果ab都符合这些先决条件,则(a+b)-b==a应该是正确的(但它是有限集合)... - ircmaxell
1
@irc 确实,我在那里用错了词。 - NullUserException
2
我想给一个+1,但是链接多而解释少。也许可以提一下十进制小数0.01在二进制下有一个循环的“10100011110101110000”(该数字看起来像0.00000010100011110101110000 …..)。然后进一步解释说,32位计算机仅能表达23个有效数字(加上8个指数位和1个符号位= 32位),这意味着它将变为0.00000010100011110101110000101 = d0.0099999979。 - stevendesu
请参考@ircmaxell在下面的答案,了解如何在使用浮点数/十进制数时保持精度。 - Jrgns
显示剩余2条评论

62
浮点数和所有数字一样,必须以0和1的字符串形式存储在内存中。对于计算机来说,这些都是位(bit)。浮点数和整数的区别在于我们查看它们时如何解释这些0和1。
其中一位(bit)是"符号"(0表示正数,1表示负数),8位(bit)是指数(范围为-128到+127),23位(bit)是分数部分,称为“尾数"。因此,(S1)(P8)(M23)的二进制表示具有值(-1^S)M*2^P。
"尾数"采用特殊形式。在常规科学计数法中,我们将“个位数”与小数部分一起显示。例如:
4.39 x 10^2 = 439
在二进制中,“个位数”是单独的一位(bit)。由于我们在科学计数法中忽略所有最左边的0(我们忽略任何无意义的数字),第一位(bit)保证是1。
1.101 x 2^3 = 1101 = 13
由于我们保证第一位(bit)是1,因此在存储数字时,我们会删除此位(bit)以节省空间。因此,上述数字仅存储为101(尾数)。前导的1被默认存在。
作为示例,让我们取二进制字符串:
00000010010110000000000000000000

将其分解为其组件:

Sign    Power           Mantissa
 0     00000100   10110000000000000000000
 +        +4             1.1011
 +        +4       1 + .5 + .125 + .0625
 +        +4             1.6875

应用我们的简单公式:

(-1^S)M*2^P
(-1^0)(1.6875)*2^(+4)
(1)(1.6875)*(16)
27

换句话说,根据IEEE-754标准,00000010010110000000000000000000在浮点数中表示为27。

然而,对于许多数字来说,没有精确的二进制表示方式。就像1/3=0.333...一样,1/100是0.00000010100011110101110000.....并且有一个重复的“10100011110101110000”。但32位计算机无法将整个数字存储在浮点数中,所以它会尽其所能地猜测。

0.0000001010001111010111000010100011110101110000

Sign    Power           Mantissa
 +        -7     1.01000111101011100001010
 0    -00000111   01000111101011100001010
 0     11111001   01000111101011100001010
01111100101000111101011100001010

(请注意,负数7是使用二进制的2's complement表示的)

很明显,01111100101000111101011100001010看起来与0.01完全不同。

更重要的是,这个二进制数包含了一个被截断的重复十进制小数。原来的十进制数包含了一个重复段"10100011110101110000",我们将其简化为01000111101011100001010。

通过我们的公式将这个浮点数转换回十进制,得到0.0099999979(请注意,这是32位计算机的精度。64位计算机会有更高的精度)。

一个十进制等价值

如果想更好地理解问题,我们可以看一下使用科学计数法表示重复小数时的十进制表示。

假设我们有10个“盒子”来存储数字。因此,如果我们想存储1/16这样的数字,我们会写:

+---+---+---+---+---+---+---+---+---+---+
| + | 6 | . | 2 | 5 | 0 | 0 | e | - | 2 |
+---+---+---+---+---+---+---+---+---+---+
这显然只是6.25e-2,其中e*10^(的简写。我们为小数分配了4个框,即使我们只需要2个框(用零补齐),并为符号分配了2个框(一个为数字的符号,一个为指数的符号)。
使用这样的10个框,我们可以显示范围从-9.9999 e -9+9.9999 e +9的数字。
对于小于或等于4个小数位数的任何内容,这都很有效,但是当我们尝试存储像2/3这样的数字时会发生什么?
+---+---+---+---+---+---+---+---+---+---+
| + | 6 | . | 6 | 6 | 6 | 7 | e | - | 1 |
+---+---+---+---+---+---+---+---+---+---+

这个新的数字0.66667并不完全等于2/3。实际上,它与2/3相差0.000003333...。如果我们试图在三进制中写出0.66667,我们将得到0.2000000000012...而不是0.2

如果我们考虑一个更大的循环小数,比如1/7,这个问题可能会变得更加明显。它有6个重复数字:0.142857142857...

将其存储到我们的十进制计算机中,我们只能显示其中5个数字:

+---+---+---+---+---+---+---+---+---+---+
| + | 1 | . | 4 | 2 | 8 | 6 | e | - | 1 |
+---+---+---+---+---+---+---+---+---+---+

这个数字0.14286的值偏离正确值.000002857...

它“接近于正确”,但并非完全正确,因此如果我们尝试将该数字转换为7进制,我们将得到一些可怕的数字,而不是0.1。实际上,将其输入Wolfram Alpha中,我们得到:.10000022320335...

这些微小的分数差异可能会让你想起0.0099999979(而不是0.01)的情况。


1
+1 谢谢,现在我知道浮点数是如何存储的了。顺便说一句,在 PHP5.3.1/Win7 中,Windows 并不支持浮点数。我曾经遇到过浮点数问题 ;) - NikiC
1
应该删除最后一段文字(声称操作系统决定是否舍入浮点值)。IEEE 754规定了浮点计算的结果,因此“0.1 + 0.2 == 0.3”在任何符合标准的系统上都必须评估为false。一些程序依赖于浮点运算以这种方式运行。 - Adam P. Goucher
1
@AdamP.Goucher 我根据您的评论在2月15日更新了我的帖子。我忘记在这里发表评论,所以现在补充一下。感谢对答案的改进。 - stevendesu

18

这里有很多关于浮点数为什么会这样工作的答案...

但是关于任意精度(Pickle提到了它)的讨论很少。如果您需要精确的计算(至少对于有理数),唯一的方法是使用BC Math扩展(实际上只是一个BigNum,任意精度 实现...)

要将两个数字相加:

$number = '12345678901234.1234567890';
$number2 = '1';
echo bcadd($number, $number2);

将会得到12345678901235.1234567890的结果……

这被称为任意精度数学。基本上所有的数字都是字符串,每个操作都要解析一次数字,并且按照数字进行操作(类似于长除法,但由库完成)。这意味着它相对于常规数学结构来说速度非常慢。但它非常强大。您可以乘、加、减、除、找到模数和求任何具有精确字符串表示的数字的幂。

因此,您无法100%准确地执行1/3,因为它有一个重复的小数(因此不是有理数)。

但是,如果您想知道1500.0015的平方:

使用32位浮点数(双精度)给出了估计结果:

2250004.5000023

但是bcmath给出了精确的答案:

2250004.50000225

这完全取决于您所需的精度。

另外,还有一点需要注意。PHP只能表示32位或64位整数(取决于您的安装)。因此,如果一个整数超出了本机int类型的大小(32位为21亿,带符号整数为9.2 x10 ^ 18或9.2百万亿),PHP将把该整数转换为浮点数。虽然这并不是立即的问题(因为所有小于系统浮点数精度的整数都可以定义为直接可表示为浮点数),但如果您尝试将两个整数相乘,它将失去显着的精度。

例如,给定$n ='40000000002'

作为数字,$n将是float(40000000002),这很好因为它被准确表示。但是,如果我们将其平方,我们得到:float(1.60000000016E+21)

使用BC数学函数作为字符串,$n将完全是'40000000002'。而且��果我们将其平方,我们得到:string(22) "1600000000160000000004"...

因此,如果您需要大数字的精度或有理小数点,则可能需要考虑使用bcmath...


4
吹毛求疵:一个数字,例如1/3,可以有无限循环小数表示,并且仍然是有理数。“有理数”指的是所有可以表示为两个整数a和b的分数的数字。而1/3确实是这样一个数字的例子。 - Mikko Rantalainen
1
+1 我来到这里寻找一种通过另一个巨大的字符串进行分割的方法,结果在你的回答中发现了 bcmath。谢谢! - mulllhausen
我认为说唯一的方法是使用bc_math有点狭隘。我会说推荐的方法是使用bc_math。如果你愿意,你可以自己实现系统:D,但这比它值得的麻烦多了。 - stevendesu

5

bcadd() 在这里可能很有用。

<?PHP

$a = '35';
$b = '-34.99';

echo $a + $b;
echo '<br />';
echo bcadd($a,$b,2);

?>

(为了清晰表达而不高效的输出)

第一行给出0.009999999999998。 第二行给出0.01。


3
因为0.01无法准确地表示为二进制小数的系列之和。这就是浮点数在内存中的存储方式。
我猜这不是你想听到的答案,但这就是问题的答案。要解决问题,请参考其他答案。

二进制什么的加法系列?浮点数并不是这样存储的。浮点数本质上是二进制科学计数法。其中一位是“符号”(0 = 正数,1 = 负数),8位是指数(范围从-128到+127),23位是数字称为“尾数”。因此,(S1)(P8)(M23)的二进制表示具有值(-1 ^ S)M * 2 ^ P。 - stevendesu
@steven_desu 谢谢你的课程。关键部分在于尾数以二进制小数形式存储。这是回答“为什么”不能精确存储十进制小数的答案。 - Andrey

2
每个数字都将以二进制值(0、1)的形式保存在计算机中。单精度数占用32位。
浮点数可以表示为:1位符号,8位指数和23位称为尾数(小数部分)。
请看下面的示例:
0.15625 = 0.00101 = 1.01*2^(-3)

enter image description here

  • 符号位:0表示正数,1表示负数,在这个例子中是0。

  • 指数部分:01111100 = 127 - 3 = 124。

    注意:偏移量为127,因此偏置指数为−3 + “偏移量”。在单精度浮点数中,偏移量为127,因此在本例中,偏置指数为124;

  • 在小数部分,我们有:1.01表示:0*2^-1 + 1*2^-2

    数字1(1.01的第一个位置)不需要保存,因为以这种方式呈现浮点数时,第一个数字总是1。例如,将0.11转换为1.1*2^(-1),0.01转换为1*2^(-2)。

另一个例子始终删除第一个零:0.1将被表示为1*2^(-1)。因此第一个数字始终为1。 表示1*2^(-1)的数字将是:

  • 0:正数
  • 127-1 = 126 = 01111110
  • 小数部分:00000000000000000000000(23个数字)

最终结果:原始二进制为: 0 01111110 00000000000000000000000

在此处检查:http://www.binaryconvert.com/result_float.html?decimal=048046053

如果您已经了解了浮点数的保存方式,那么如果一个数字无法保存在32位(单精度)中会发生什么。

例如:十进制中,1/3 = 0.3333333333333333333333,因为它是无限的,所以我们假设有5位来保存数据。请注意,这不是真实的情况,只是假设。因此,在计算机中保存的数据将是:

0.33333.

现在当数字被加载时,计算机会再次进行计算:
0.33333 = 3*10^-1 + 3*10^-2 + 3*10^-3 + 3*10^-4 +  3*10^-5.

关于这个:
$a = '35';
$b = '-34.99';
echo ($a + $b);

结果为0.01(十进制)。现在让我们把这个数字显示成二进制。
0.01 (decimal) = 0 10001111 01011100001010001111 (01011100001010001111)*(binary)

请查看这里:http://www.binaryconvert.com/result_double.html?decimal=048046048049

因为(01011100001010001111)像1/3一样是无限循环的。所以计算机无法在它们的内存中保存这个数字。必须进行牺牲。这导致计算机不准确。

高级(您必须具备数学知识) 那么为什么我们可以轻松地在十进制中显示0.01,但不能在二进制中显示呢?

假设0.01(十进制)的二进制分数是有限的。

So 0.01 = 2^x + 2^y... 2^-z
0.01 * (2^(x+y+...z)) =  (2^x + 2^y... 2^z)*(2^(x+y+...z)). This expression is true when (2^(x+y+...z)) = 100*x1. There are not integer n = x+y+...+z exists. 

=> So 0.01 (decimal) must be infine in binary.

0

使用PHP的round()函数:http://php.net/manual/en/function.round.php

这个答案解决了问题,但没有解释为什么。我认为这是显而易见的[我也在C++中编程,所以对我来说很明显;],但如果不是这样的话,那么可以说PHP有它自己的计算精度,在那种特定的情况下,它返回了最符合该计算的信息。


7
因为它绝对不是对问题的回答。 - Dennis Haarbrink
那不是原帖所问的。附注:我没有给你点踩。 - NullUserException
@Dennis:我编辑了我的答案,请再考虑一下你的投票。;] - Tomasz Kowalczyk
@NullUserException 我认为你的回答更好,但这个也还不错,不值得接受,但也不应该被踩。通常当人们以那种方式提问时,他们通常想知道如何修复它,而不是背后的哲学。 - Andrey
1
@Tomasz Kowalczyk:好的,你已经收到了3个赞和2个踩,总计26reputation。我认为这应该足够支持你的回答 :) - Dennis Haarbrink
显示剩余4条评论

0

使用number_format(0.009999999999998, 2)或者$res = $a+$b; -> number_format($res, 2);会更容易些,不是吗?


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