浮点数的事为什么总是这么糟糕(二):特殊值

这是计划中一系列讨论浮点数的文章,会涵盖浮点数的标准、误差、异常等细节,以及不同软硬件平台对浮点数的支持等问题。为了阅读和表达的方便,我选择用问答的形式表现。

如果这系列问答对你带来了帮助,或者有什么遗漏、错误之处,请不吝在评论区回复,谢谢。


本期来谈谈浮点数里一些特殊的值。前一期里提到,浮点数和数学上的实数并不完全等同。但因为四则运算的需要,浮点数的取值依然定义了若干特殊情况。

#7 除以0怎么办

数学上,以0为除数的除法自然没有意义,但计算机设计者无法要求程序在遇到这种除法时就退出甚至关机,所以必然要纳入考虑。

对于被除数非零的情况,如 3.0/0.0,把0当作一个无穷小算是自然的考虑,这样就可以得出结论,结果是 +∞ 正无穷大。虽然数学上的无穷大并不算做一个数,但浮点数标准真的定义了这个值。

如果被除数是0,即 0.0/0.0,情况就变棘手了。这个结果该是多少呢?无穷大?不对劲。等于1?也不合理,因为这个结果显然该和正常的1有所区分。为此,IEEE-754额外又定义了一类特殊值,叫做「非数」(NaN, Not a Number)。请注意,这里说的是「一类」而非「一个」,因为NaN真的也不是一个固定表示的数。

但对于除0,浮点数也不只是生成无穷大或NaN就完了,大多数CPU还会生成一个叫做「浮点异常」的东西。浮点异常和编程语言里的异常概念并不等同,它要底层得多,规定在CPU的指令集文档里。在后续讨论浮点环境的章节,我们会深入讨论浮点异常。

Python是个例外,Python解释器对浮点数除0也会生成ZeroDivisionError异常。想在Python里获得无穷大,可以通过math.inf常量。

#8 什么是无穷大

用一个非0的浮点数除以0,会产生一个叫做无穷大的值。逻辑上,这个无穷大依然还要按照浮点数的结构,表示为一串二进制位。根据浮点数标准,指数全1而尾数全0的数被规定为无穷大。因为还有一个符号位,所以无穷大也分为正无穷大和副无穷大。这倒很合理,1.0/0.0是正无穷大,-1.0/0.0是负无穷大嘛。

用第一章的dumpFloat64()函数打印一个正无穷大的值:

> dumpFloat64(1.0/0.0)
Sign 0
Exponent 11111111111
Mantissa 0000000000000000000000000000000000000000000000000000

#9 如何判断一个数是否是无穷大

因为正负无穷大各自都有惟一表示,因此可以直接用相等去判断一个数是否是无穷大。但出于可读性考虑,以及避免遗漏正负情况,大多数语言都提供了数学函数做这个判断:

  • C/C++: 标准库math.h的isinf(x)或Clang/GCC扩展的__builtin_isinf(x)
  • Java: Double.isInfinite(x)Float.isInfinite(x)
  • Python: math.isinf(x)
  • Ruby: x.infinite?
  • JavaScript: !Number.isNaN(x) && !Number.isFinite(x)

#10 无穷大和其他浮点数的关系是怎样的

和你想的一样:

  • 对于既不是无穷大也不是NaN的浮点数,正无穷大比它们都大,负无穷大比它们都小,正无穷大自然也比负无穷大大
  • 正无穷大或负无穷大都和自己相等

#11 浮点数0的表示是惟一的吗

和你想的……不一样,浮点数的0并不惟一!虽然0的指数和尾数全部为0,但还有一个符号位,既可以为0也可以为1. 如果符号位为1,实际上这个值会变成「负零」!

> dumpFloat64(-0.0)
Sign 1
Exponent 00000000000
Mantissa 0000000000000000000000000000000000000000000000000000

可能你的第一反应是,0的符号位没有任何意义。但这是独立符号位的必然结果,定点数采用补码就避免了两个0的情况。我们也的确可能得到不同符号的0:

> -0 - 0
-0
> -0 + 0
0

不同符号的0自然也会导出不同符号的无穷大:

> 1.0 / 0.0
Infinity
> -1.0 / 0.0
-Infinity

好在根据标准,这两个0永远相等,不用再专门调用某个名似iszero的函数了(尽管某些语言还真有)。

#12 为什么会存在NaN这种东西

浮点数是对数学运算的模拟,因此需要有个概念来表达「运算错误」,否则浮点数作为类型的定义就是不完整的。实用地说,除了两个0相除这种情况,sqrt(-1)或者log(-1)同样会返回NaN,因为在没有复数的情况下,并没有一个正常数值能够表示这种错误结果。

除此之外,NaN参与运算的结果也会是NaN:

> NaN + 5
NaN
> Math.sqrt(NaN)
NaN
> NaN * 0
NaN

例外是比较操作,单独的NaN和任何数的任何比较都是false,包括它自己:

> NaN > -Infinity
false
> NaN === NaN
false
> NaN > 0
false

在JavaScript里,parseFloatparseInt解析到非法内容,也会返回NaN:

> parseFloat('x')
NaN
> parseInt('?')
NaN

#13 NaN是如何表示的

指数全1而尾数全0的数用以表示无穷大,而指数全1尾数非全0就被用来表示NaN了。

> dumpFloat64(NaN)
Sign 0
Exponent 11111111111
Mantissa 1000000000000000000000000000000000000000000000000000

只要尾数部分不全为0,不管具体是多少都是NaN. 这也意味着NaN的表示并不惟一,我们不能再用等号去判断一个数是否是NaN,更何况,本来NaN和自己比较相等的结果就是false.

然而……尾数不同的NaN可能亦有区别。

#14 如何判断一个数是否是NaN

最简单的方法,当然是和判断无穷类似:调库。

  • C/C++: isnan(x)或Clang/GCC扩展的__builtin_isnan(x)
  • Java: Double.isNaN(x)Float.isNaN(x)
  • Python: math.isnan(x)
  • Ruby: x.nan?
  • JavaScript: Number.isNaN(x)

不过既然我们已经知道了原理,不妨实现一个土法isNaN:

function poorIsNaN(value) {
  let buffer = new ArrayBuffer(8)
  let float64 = new Float64Array(buffer)
  float64[0] = value
  let uint8 = new Uint8Array(buffer)
  // Do not reverse on Big Endian
  let binary = Array.from(uint8).reverse()
  return (binary[0] & 127) === 127 &&
    (binary[1] & 240) == 240 &&
    ((binary[1] & 15) | binary[2] | binary[3] |
      binary[4] | binary[5] | binary[6] | binary[7]) != 0
}

要注意,这段代码对大端平台并不管用。

#15 NaN的尾数部分有何影响

根据IEEE-754标准,尾数部分第一位为0的NaN被称作Signaling NaN,以1开头的则被称作Quiet NaN. 在大多数硬件架构上,对Signaling NaN的操作会触发浮点异常,而Quiet NaN则不会。


发表回复