比较浮点数值有多危险?

420

我知道UIKit使用CGFloat是因为它具有分辨率无关的坐标系统。

但每次我想检查例如frame.origin.x是否为0时,这让我感到不舒服:

if (theView.frame.origin.x == 0) {
    // do important operation
}

CGFloat在使用==<=>=<>进行比较时,是否容易出现误判呢?毕竟它是浮点数,存在精度问题,例如:0.0000000000041

在比较过程中,Objective-C是否会内部处理这个问题呢?或者说可能会出现origin.x读取为零但与0进行比较结果不为真的情况吗?


1
这主要是针对非整数值的问题,其中舍入误差很容易发生。我写了一篇博客文章,描述了何时会发生舍入误差以及如何估计潜在误差的大小。 - Hampus
12个回答

505

首先,浮点数值在行为上并不是“随机”的。在许多实际应用中,确切的比较是有意义的。但是,如果你要使用浮点数,你需要了解它的工作原理。过于假设浮点数像实数一样工作会导致代码很快就会出错。而过于假设浮点数结果具有大量随机模糊性(就像这里的大多数答案建议的那样)会导致代码在开始时似乎正常工作,但最终出现大幅度错误和破碎的角落情况。

首先,如果您想使用浮点数编程,您应该阅读以下内容:

计算机科学家应该了解的浮点运算知识

是的,请全部阅读。如果太过繁琐,您应该在阅读完之前使用整数/定点进行计算。 :-)

现在,说完这些,精确浮点比较的最大问题在于:

  1. 很多你在源代码中写入或使用 scanfstrtod 读取的值,实际上并不存在浮点数值,并会被静默地转换为最近的近似值。这就是 demon9733 回答中所讨论的内容。

  2. 由于没有足够的精度来表示实际结果,很多结果会被四舍五入。一个简单的例子是将 x = 0x1fffffey = 1 相加作为浮点数。这里,x 的尾数有 24 位精度(好的),而 y 只有 1 位,但当它们相加时,它们的位不重叠,结果需要 25 位精度。相反,它会被四舍五入(在默认舍入模式下为 0x2000000)。

  3. 由于需要无限多的位才能得到正确的值,很多结果会被四舍五入。这包括有理数结果,如 1/3(你从小数中熟悉它需要无限多的位),但也包括二进制中也需要无限多的位的 1/10(因为 5 不是 2 的幂),以及任何不是完全平方数的数字的平方根等无理数结果。

  4. 双重舍入。在某些系统上(特别是 x86),浮点表达式会以高于其名义类型的精度进行评估。这意味着当上述某种类型的四舍五入发生时,你将得到两个舍入步骤,首先将结果舍入到高精度类型,然后再舍入到最终类型。例如,在十进制中,如果你将 1.49 舍入为整数(1),与首先将其舍入到一位小数(1.5),然后将该结果舍入为整数(2)相比较的结果。这实际上是处理浮点数中最棘手的领域之一,因为编译器的行为(尤其是对于有缺陷、不符合规范的编译器,如 GCC)是不可预测的。

  5. 超越函数(trigexplog 等)没有指定正确舍入的结果;结果仅指定在精度的最后一位上正确(通常称为1ulp)。

当您编写浮点数代码时,需要牢记进行数字处理可能导致结果不精确的操作,并相应地进行比较。通常情况下,将使用“epsilon”进行比较是有意义的,但该“epsilon”应基于所比较的数字的大小,而不是绝对常数。(在绝对常数epsilon起作用的情况下,这强烈表明固定点而非浮点是正确的工具!)
编辑:特别地,大小相关的 epsilon 检查应该看起来像:
if (fabs(x-y) < K * FLT_EPSILON * fabs(x+y))

其中FLT_EPSILON是来自float.h的常量(对于double,请将其替换为DBL_EPSILON,对于long double,请将其替换为LDBL_EPSILON),而K是您选择的一个常量,使得计算误差的累积肯定受到最后一位上K个单位的限制(如果您不确定您的误差边界计算是否正确,请将K设为比您计算得出的值大几倍)。

最后,请注意,如果您使用此方法,在接近零时可能需要特别注意,因为FLT_EPSILON对于非规格化数没有意义。一个快速的解决办法是将其设置为:

if (fabs(x-y) < K * FLT_EPSILON * fabs(x+y) || fabs(x-y) < FLT_MIN)

如果使用双精度,请同样替换DBL_MIN


26
如果xy(可以)有不同的符号,那么fabs(x+y)会有问题。尽管如此,它仍然是一种好的回答方式,反对模仿比较的潮流。 - Daniel Fischer
30
如果 xy 符号不同,那么这没有问题。右边将会变得“太小”,但由于 xy 符号不同,它们本来就不应该相等。(除非它们非常小以至于成为非规格化数,但这种情况会被第二种情况捕获) - R.. GitHub STOP HELPING ICE
6
我对你的说法很好奇:“特别是对于GCC这样的有错误和不符合标准的编译器”。 GCC真的存在错误和不符合标准的情况吗? - Nicolás Ozimica
3
由于该问题标记为iOS,值得注意的是,苹果的编译器(包括clang和苹果的gcc版本)一直使用FLT_EVAL_METHOD = 0,并且试图完全严格地不携带多余的精度。如果您发现任何违规情况,请提交错误报告。 - Stephen Canon
20
首先,浮点数值在行为上并不是“随机的”。在许多实际应用中,精确比较是有意义且可以实现的。这只有两个句子就得到了+1赞!这是人们在使用浮点数时最令人不安的错误假设之一。 - Christian Rau
显示剩余16条评论

41

由于0可以被IEEE754浮点数(或我所接触的其他实现浮点数的方式)准确表示,因此与0进行比较可能是安全的。但是,如果程序计算出一个值(例如theView.frame.origin.x),你有理由认为它应该是0,但是你的计算无法保证它是0,则可能会遇到问题。

稍微澄清一下,例如以下计算:

areal = 0.0

除非你的语言或系统出问题,否则将会创建一个值,使得 (areal==0.0) 返回 true,但是其他计算结果可能不同。

areal = 1.386 - 2.1*(0.66)

如果您能确保自己的计算产生的值为0(而不仅仅是它们应该为0),那么您可以比较f-p值和0。 如果无法以所需的程度保证,请最好坚持使用“公差相等”的常规方法。

在最糟糕的情况下,粗心比较f-p值可能非常危险:例如航空电子设备、武器引导、发电厂运营、车辆导航等几乎所有涉及计算与现实世界相遇的应用程序中。

对于《愤怒的小鸟》,则不太危险。


13
实际上,如果您的编译器采用IEEE 754标准,那么1.30 - 2*(0.65)就是一个完美的例子,显然计算结果为0.0,因为用0.651.30表示的双精度数具有相同的有效数字,并且乘以二显然是精确的。 - Pascal Cuoq
7
这个还在不断地得到回应,所以我改了第二个示例片段。 - High Performance Mark

24

我希望给出一个与其他人不同的答案。就你提出的问题而言,他们回答得很好,但可能并不能解决你实际需要了解的问题。

在图形中使用浮点数是可以的!但几乎没有必要直接比较浮点数。为什么需要这样做呢?图形使用浮点数来定义间隔。如果需要比较浮点数是否在由浮点数定义的区间内,这总是很明确的,并且只需要保持一致性,而不是精确或准确!只要像素(也是一个间隔!)能够被指定,这就是图形所需的全部。

因此,如果您想测试您的点是否在[0..width[范围之外,这是完全可以的。只需要确保定义包含一致即可。例如,始终将内部定义为(x>=0 && x < width)。相同的适用于交集或命中测试。

但是,如果您滥用图形坐标作为某种标志,例如查看窗口是否停靠,您不应该这样做。请使用与图形表示层分离的布尔标志。


15
与零比较可以是安全的操作,只要零不是一个计算出来的值(如上面的答案所述)。这是因为浮点数中的零是一个完全可表示的数字。
谈到完全可表示的值,单精度浮点数有2的幂次方概念下的24位范围。因此1、2、4是完全可表示的,0.5、0.25和0.125也是完全可表示的。只要所有重要的位在24位内,你就没问题了。所以10.625可以准确地表示。
这很好,但在压力下很快就会崩溃。有两种情况需要注意: 1)当涉及计算时。不要相信sqrt(3)*sqrt(3) == 3。它不会那样。而且它可能不会在epsilon之内,就像其他答案建议的那样。 2)当涉及任何非2的幂次方(NPOT)时。所以听起来可能很奇怪,但0.1在二进制中是一个无限级数,因此涉及这样的数字的任何计算都将从一开始就不精确。
(哦,原问题提到与零进行比较。别忘了-0.0也是一个完全有效的浮点值。)

12

[“正确答案”忽略了选择K的复杂性。选取K实际上与选择VISIBLE_SHIFT一样主观,但选择K并不像VISIBLE_SHIFT那样基于任何显示属性。因此你要在选取K或选取VISIBLE_SHIFT之间进行抉择。本答案建议选择VISIBLE_SHIFT,并展示了选择K的困难之处。]

由于存在舍入误差,您不应该使用“精确”值的比较来进行逻辑操作。在您特定的情况下,对于视觉显示中的位置,0.0或0.0000000003这样微小的差别都无法被肉眼察觉。因此,您的逻辑应该是:

#define VISIBLE_SHIFT    0.0001        // for example
if (fabs(theView.frame.origin.x) < VISIBLE_SHIFT) { /* ... */ }
然而,最终的“肉眼不可见”取决于您的显示属性。如果您可以对显示进行上限约束(应该可以),则选择 VISIBLE_SHIFT 作为该上限的一小部分。

现在,“正确答案”取决于K,让我们探讨如何选择K。上述“正确答案”指出:

  

K是您选择的常数,使得计算中的累积误差在最后一位上明确地受到K个单位的限制(如果您不确定是否正确地计算了误差界限,请将K设为大于计算所需值的几倍)

因此,我们需要K。如果获取K比选择我的VISIBLE_SHIFT更困难、不太直观,那么您就可以决定哪种方法适合您。为了找到K,我们将编写一个测试程序,查看一堆K值的行为方式。如果“正确答案”可用,那么选择K应该很明显,不是吗?

我们将使用上述“正确答案”细节:

if (fabs(x-y) < K * DBL_EPSILON * fabs(x+y) || fabs(x-y) < DBL_MIN)

让我们尝试所有K的值:

#include <math.h>
#include <float.h>
#include <stdio.h>

void main (void)
{
  double x = 1e-13;
  double y = 0.0;

  double K = 1e22;
  int i = 0;

  for (; i < 32; i++, K = K/10.0)
    {
      printf ("K:%40.16lf -> ", K);

      if (fabs(x-y) < K * DBL_EPSILON * fabs(x+y) || fabs(x-y) < DBL_MIN)
        printf ("YES\n");
      else
        printf ("NO\n");
    }
}
ebg@ebg$ gcc -o test test.c
ebg@ebg$ ./test
K:10000000000000000000000.0000000000000000 -> YES
K: 1000000000000000000000.0000000000000000 -> YES
K:  100000000000000000000.0000000000000000 -> YES
K:   10000000000000000000.0000000000000000 -> YES
K:    1000000000000000000.0000000000000000 -> YES
K:     100000000000000000.0000000000000000 -> YES
K:      10000000000000000.0000000000000000 -> YES
K:       1000000000000000.0000000000000000 -> NO
K:        100000000000000.0000000000000000 -> NO
K:         10000000000000.0000000000000000 -> NO
K:          1000000000000.0000000000000000 -> NO
K:           100000000000.0000000000000000 -> NO
K:            10000000000.0000000000000000 -> NO
K:             1000000000.0000000000000000 -> NO
K:              100000000.0000000000000000 -> NO
K:               10000000.0000000000000000 -> NO
K:                1000000.0000000000000000 -> NO
K:                 100000.0000000000000000 -> NO
K:                  10000.0000000000000000 -> NO
K:                   1000.0000000000000000 -> NO
K:                    100.0000000000000000 -> NO
K:                     10.0000000000000000 -> NO
K:                      1.0000000000000000 -> NO
K:                      0.1000000000000000 -> NO
K:                      0.0100000000000000 -> NO
K:                      0.0010000000000000 -> NO
K:                      0.0001000000000000 -> NO
K:                      0.0000100000000000 -> NO
K:                      0.0000010000000000 -> NO
K:                      0.0000001000000000 -> NO
K:                      0.0000000100000000 -> NO
K:                      0.0000000010000000 -> NO
啊,所以如果我想要1e-13被视为“零”,那么K应该是1e16或更大。
因此,我认为你有两个选项:
1. 使用你的工程判断(如我所建议的)对“epsilon”的值进行简单的epsilon计算。如果你正在做图形,并且“零”意味着“可见变化”,那么请检查你的视觉资产(图像等),并判断epsilon可以是什么值。
2. 先阅读非传统回答的参考资料(并在此过程中获得博士学位),然后使用你的非直观判断来选择K,再尝试浮点运算。

10
分辨率无关性的一个方面是,在编译时你无法确定“可见位移”是什么。在超高清屏幕上看不见的东西,在小屏幕上可能非常明显。至少应该将其作为屏幕大小的函数,或者给它取一个别的名字。 - Romain
2
但至少选择“可见移位”是基于易于理解的显示(或帧)属性 - 不像<正确答案>中的K,它难以选择且不直观。 - GoZoner

6

正确的问题是:如何比较Cocoa Touch中的点?

正确的答案:使用CGPointEqualToPoint()。

不同的问题:两个计算出的值是否相同?

这里发布的答案是:它们不相同。

如何检查它们是否接近?如果您想检查它们是否接近,则不要使用CGPointEqualToPoint()。但是,不要检查它们是否接近。在现实世界中做些有意义的事情,例如检查一个点是否超出了一条线或一个点是否在球体内。


4
我上次检查C标准时,并没有要求double(64位总,53位尾数)上的浮点运算精度超过该精度。然而,一些硬件可能会在更高精度的寄存器中进行操作,因此将该要求理解为没有清除低阶位(超出加载到寄存器中的数字精度之外)的要求。因此,根据上一个操作者在寄存器中留下的内容,您可能会得到意想不到的比较结果。
话虽如此,尽管我一看到它就试图清除它,但我们公司编写的许多C代码都是使用gcc编译并在Linux上运行的,我们已经很长时间没有注意到任何这些意外的结果了。我不知道这是因为gcc正在为我们清除低阶位,现代计算机上不使用80位寄存器来执行这些操作,标准已经改变了,还是其他原因。如果有人能引用章节和文章,我想知道。

0

需要记住的另一个问题是不同实现的做法可能不同。我非常熟悉的一个例子是索尼Playstation 2上的FP单元。与X86设备中的IEEE FP硬件相比,它们存在显著的差异。引用的文章提到了inf和NaN完全缺乏支持,情况变得更糟。

较少人知道的是我所知道的“一位乘法”错误。对于某些float x的值:

    y = x * 1.0;
    assert(y == x);

会失败断言。一般情况下,有时候但并非总是,Playstation 2上的FP乘法结果的尾数比等效的IEEE尾数少一个位。

我的观点是,你不应该假设将FP代码从一个平台移植到另一个平台会产生相同的结果。任何给定的平台在内部是一致的,即结果在该平台上不会改变,只是可能与其他平台不一致。例如,X86上的CPython使用64位双精度表示浮点数,而Cortex MO上的CircuitPython必须使用软件FP,并且只使用32位浮点数。不用说,这将引入差异。

我在40多年前学到的一句话至今仍然如实。"在计算机上进行浮点数运算就像移动一堆沙子。每次你做任何事情,都会留下一点沙子,捡起一点灰尘。"

Playstation是索尼公司的注册商标。


0
您可以使用以下代码将浮点数与零进行比较:
if ((int)(theView.frame.origin.x * 100) == 0) {
    // do important operation
}

这将以0.1的精度进行比较,在这种情况下足够CGFloat使用。


1
theView.frame.origin.x强制转换为int,而不确保其在/接近int范围内会导致未定义的行为(UB)-或在这种情况下,是int范围的1/100。 - chux - Reinstate Monica
1
完全没有必要像这样转换为整数。正如chux所说,存在超出范围值的UB潜在风险;在某些架构上,这比仅在浮点数中进行计算要慢得多。最后,像这样乘以100将与0.01精度进行比较,而不是0.1。 - Sneftel

-1

我正在使用以下比较函数来比较一定数量的小数位:

bool compare(const double value1, const double value2, const int precision)
{
    int64_t magnitude = static_cast<int64_t>(std::pow(10, precision));
    int64_t intValue1 = static_cast<int64_t>(value1 * magnitude);
    int64_t intValue2 = static_cast<int64_t>(value2 * magnitude);
    return intValue1 == intValue2;
}

// Compare 9 decimal places:
if (compare(theView.frame.origin.x, 0, 9)) {
    // do important operation
}

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