浮点数的事为什么总是这么糟糕(一):格式

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

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


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给删了——大概是不懂浮点数的人真的很多!当然不懂正则表达式的程序员可能也不少,但相比而言,除开某些情况下的性能问题,使用正则表达式的人更多属于「知其不知」;而浮点数则由于常常被和数学上的小数划等号,导致对其不了解也可以(看起来)正常使用,「不知其不知」,这就更危险了。

#1 什么是浮点数

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

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

#2 浮点数和整数有什么区别

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

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

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

#3 什么是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作为表示格式。

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

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

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

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

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

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

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

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

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

根据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) 浮点数。后面会详细讨论非规格化浮点数的使用。


发表回复