Chaofan's

Bonvenon al la malpura mondo.

标签: 浮点数

  • 浮点数的NaN和无穷大

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

    浮点数有零吗

    除以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常量。

    浮点数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的函数了(尽管某些语言还真有)。

    无穷大

    什么是无穷大

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

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

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

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

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

    • 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)

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

    和你想的一样:

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

    NaN

    为什么会存在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

    NaN是如何表示的

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

    > dumpFloat64(NaN)
    Sign 0
    Exponent 11111111111
    Mantissa 1000000000000000000000000000000000000000000000000000

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

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

    如何判断一个数是否是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
    }

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

    NaN的尾数部分有何影响

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

  • 浮点数的格式与存储

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

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


    You should not be permitted to write production code if you do not have a journeyman license in regular expressions or floating point math. – Rob Pike

    这是几年前Rob Pike在Twitter上的一句抱怨。很多人感叹真有道理,但他最后还是把这条Tweet给删了——大概是不懂浮点数的人真的很多!当然不懂正则表达式的程序员可能也不少,但相比而言,除开某些情况下的性能问题,使用正则表达式的人更多属于「知其不知」;而浮点数则由于常常被和数学上的小数划等号,导致对其不了解也可以(看起来)正常使用,「不知其不知」,这就更危险了。

    什么是浮点数

    有时候我们需要处理的数据可能(绝对值)非常大或者非常小,这时数据末尾的零或小数点后的零可以被忽略掉,比如对9000000000和0.000000000009,我们要关心的其实只有9这个数字。如果一组数据的范围差不多,那么可以在代码里做个约定,比如数的实际值是存储值的100000000倍。但如果每个数的范围都不一样,那就得把前面的倍数信息也存在这个数内部,这就是浮点数「浮」的含义。

    粗浅理解,前面的9000000000可以把有意义数字和10的指数存储为(9, 9),0.000000000009则是(9, -12).

    浮点数和整数有什么区别

    得益于软件工程对人类生活的不断渗透,普通程序员能接触到的主要复杂度都来自于和社会直接挂钩的那部分——业务,除此之外就是人类社会对业务中共通部分的抽象,比如各国货币、历法和文字。浮点数有些特殊,其本质上是一组数学上的定义,复杂度更多来自这组定义的繁冗细节,以及和真实世界的「错位」。

    一些语言为了方便初学者,会把浮点数类型命名为REAL (实数),但这种做法实际上更加误导人。首先实数有无穷多个,而一个N位浮点数最多也只能表示2N个不同的数。并且浮点数并不是所有情况下都无法精确表示整数。实际上整数和实数都是数学概念。在计算机领域,和浮点数 (floating-point) 相对的是定点数 (fixed-point),也就是不把指数信息保存在内部的数据类型。一般编程语言中的INT类型即是定点数。

    通常来说,因为定点数的定义更为简单,程序中会用它表示整数。在含义确定的情况下,也可以用它表示小数点位数固定的数。

    什么是IEEE-754标准

    IEEE-754是目前最广为接受的浮点数标准,由IEEE于1985年制定,经过2008年和2019年两次修订。目前,几乎所有平台的32位和64位浮点数都兼容这个标准。强调32位和64位对IEEE-754的兼容,是因为16位和128位的情况有些不同。

    除此之外,早期IBM System/360系列大型主机还支持一种称为Hexadecimal Floating Point的格式,现代的IBM Z作为后继者同时支持这两种格式。但无论哪种浮点数格式,数据都如前文所述由三部分组成,区别主要在于后两部分的长度比例,和一些运算细节的处理。

    所有的标准都是冗长的,IEEE-754这种涉及大量数学定义的自然更不例外。所以这里整理出一些关键的地方:

    • 一个浮点数可以表示五种值之一:qNan、sNan、正无穷、负无穷,以及普通的由符号、指数、尾数构成的三元组浮点数
    • 浮点数尾数除了二进制,还可以是十进制的
    • 浮点数不遵循结合律

    后续内容如无特别注明,浮点数均以IEEE-754作为表示格式。

    单精度和双精度浮点数有什么区别

    单精度浮点数指长度为32位的浮点数,双精度浮点数指长度为64位的浮点数。在IEEE-754标准中,它们也被称作binary32和binary64,以和decimal浮点数相区分。

    参考前面介绍的浮点数概念,两者的主要区别在有效值 (尾数,fraction) 和指数 (exponent) 的取值范围上。

    总长度尾数长度指数长度
    单精度32位23位8位
    双精度64位52位11位

    在多数编程语言中,单精度浮点数的数据类型为single或float,双精度浮点数的类型则叫做double.

    浮点数的尾数和指数是什么

    先前介绍了浮点数「概念上」的表示格式:非零部分+进位指数,如300000就是3和5. 现实中的浮点数也分成这两部分存储,另外还带上1位记录符号。这里的非零部分就是尾数,而指数就对应浮点数里的指数。

    注意,我们讨论的都是二进制浮点数,所以实际上尾数和指数都是二进制。你可能会说,二进制和十进制不是本来可以相互转换吗,那为什么要注意这个区别?对于指数部分,其本就是定点数,数值只存在溢出不存在精度问题,所以什么进制都没区别。但尾数因为有舍入操作,不同进制下舍入造成的误差相差甚大。后面会继续讨论浮点数误差的问题。

    浮点数在内存中具体如何存储

    根据IEEE-754标准,浮点数三部分在内存中存储的顺序为符号位、指数和尾数。使用以下JavaScript代码能够打印出一个双精度浮点数的三部分二进制值:

    function dumpFloat64(value) {
      let buffer = new ArrayBuffer(8)
      let float64 = new Float64Array(buffer)
      float64[0] = value
      let uint8 = new Uint8Array(buffer)
      let binary = Array.from(uint8).reverse().map(b => b.toString(2).padStart(8, '0')).join('')
      console.log('Sign', binary[0])
      console.log('Exponent', binary.substr(1, 11))
      console.log('Mantissa', binary.substr(12, 52))
    }

    注意,这段代码为了简便,假定运行机器的字节序 (endianness) 为小端序 (little-endian),好在绝大多数消费级CPU架构都是小端序。

    为了避开误差问题,这里以一个二进制能够精确表示的浮点数0.25作例子解读。

    Sign 0
    Exponent 01111111101
    Mantissa 0000000000000000000000000000000000000000000000000000

    符号位是0没有问题,因为0.25是正数。有意思的是,指数位转换到十进制是1021,而尾数部分竟然全零!

    指数开头是0,因此不像补码。看看输出1.0的结果,指数变成了1023,而1.0是0.25的4倍(因为这里是二进制浮点数,后面的底数是2而不是10),所以真实的指数应该减去1023,也就是-2,实际存储的是加上1023后的偏移量。这倒是符合我们的思路,0.25=1×2-2,但为何尾数部分都是0呢,不应该有个1吗?

    其实这里已经假定,1在尾数部分的开头,也就是说实际存储的尾数是省去了开头1的结果。这是IEEE-754给浮点数的额外规定,一是保证同一个数的表示方式惟一,二则为了节省额外一位空间。比如0.25既可以表示作1×2-2也可以写作0.5×2-1,而这种约束保证了实际采用的一定是指数最小的那种表示法。在一个符合IEEE-754标准的浮点计算环境中,每次运算完后的结果都会按照这个约束进行调整,这个操作称作规格化 (normalization)。

    思考一下又会发现,强制要求所有浮点数都是规格化的,会导致一些「特别特别小」的浮点数无法表示。比如,最小的规格化双精度浮点数是1×2-1023,但如果放弃这个约束,因为尾数部分的小数点还能够往左移。在这种极端情况下,的确有浮点数可以不遵守规格化约束,我们称呼为非规格化 (denormal) 浮点数。后面会详细讨论非规格化浮点数的使用。