详解浮点数,为什么浮点数不能直接比较?

1 引言

昨天与靓神聊到浮点数精度丢失的问题,于是今天写一篇文档来详细描述现代计算机的浮点数存储方式,进而解答相关的一些问题:

  • 明明是小数,为什么程序里要叫浮点数?
  • 什么是浮点数的精度,为什么会发生精度丢失?为什么叫浮点数为近似表示?
  • 为什么浮点数不能直接比较?
  • 浮点数的范围,为什么float32的范围远远大于uint32?
  • 浮点数为什么不能用位操作?

首先我们来看下面这段代码,请问输出结果是什么:

func main() {
	a, b := 1.5, 1.3
	fmt.Println(a-b == 0.2)
	fmt.Println(a-b > 0.2)
}
  • 第1行输出,不少同学应该能知道,浮点数不能直接比较,结果会是false。
  • 第2行输出,结果会是true。

如果上面的示例没惊奇到你,那么我们再看这个示例:

func main() {
	a := float32(16777216)
	fmt.Println(a == a+1)
	a = math.MaxFloat32
	fmt.Println(a == a-float32(math.MaxUint32))
}

很神奇,上面这段代码的输出结果是"true true",即我们的代码认为16777216 = 16777216+1,而且最大的float32数减去最大的32位整形(42亿多)结果居然还是等于原值。

上述“违反常理”问题的原因与浮点数的计算机表示方式有关。后续章节我会先简单介绍浮点数的表示方式,然后再解答上面的问题。
如果你只是想知道一个通用的比较浮点数的方法,下面这段代码可能有所帮助:

/*
	f1/f2为待比较的参数,degree为数据的精度
	比如:cmpFloat32(1.5, 1.3, 0.000001)返回结果为1
	注意:精度degree需要根据实际场景自行调整
*/
func cmpFloat32(f1, f2, degree float32) int {
	if f1 + degree > f2 && f1 - degree < f2 {
		return 0	// 相等
	} else if f1 < f2 {
		return -1	// f1比f2小
	} else {
		return 1	// f1比f2大
	}
}

2 浮点数的计算机表示

2.1 小数的二进制表示

我们都知道计算机只识别0和1,整数在计算机内是二进制形式,小数也只能是二进制表示。

一个小数可以分为3部分:整数部分、小数点、小数部分。

以10.75为例,十进制的转换规则是:10.75 = 1*10^1 + 0*10^0 + 7*10^-1 + 5*10^-2。注意,小数部分取的是模数的负的指数,即模数的指数的倒数。
对于二进制,转换思路是一样的:10.75 = 1*2^3 + 0*2^2 + 1*2^1 + 0*2^0 + 1*2^-1 + 1*2^-2,于是10.75的二进制就是1010.11
对于一个复杂的小数,上述转换公式很难直接写出,所以下面介绍一种方便计算的思路:

  • 整数部分,大家很容易想到的编程思路:不断除以2并对2取余得到的0或1即是对应位的二进制值,当整数部分为0时停止。
  • 小数部分,则正好与整数相反,不断乘以2,溢出部分会是0或1,这正是小数的二进制值,当小数部分为0时停止。

以10.125为例,整数部分我们直接给出是1010:

0.125 * 2 = 0.25,整数部分溢出为0,则表示1010.0
0.25 * 2 = 0.5,溢出还是0,1010.00
0.5 * 2 = 1.0,溢出是1,1010.001
剩余小数部分为0,计算停止,最终结果10.125的二进制表示是1010.001

所以二进制表示的小数,也是3部分,其中整数和小数部分都是0/1组成,但小数点及小数点的位置,不能直接用0/1表示,于是我们需要一种方式来处理小数点。
当今主流编程语言都采用IEEE-754标准,这个标准规定了浮点数的二进制表示格式、操作方式、舍入模式及异常处理等。

2.2 IEEE-754标准

前面介绍了浮点数的二进制表示,而IEEE-754我们主要关注点可以集中在它在存储浮点数二进制时是怎么处理小数点的。
以golang的单精度float32为例,IEEE-754标准的float32如下:

s-eeee eeee-ffff ffff ffff ffff ffff fff

一个32位的单精度浮点数的32个bit位被划分为定长的3个组成部分:

  • 符号域S:第0位表示符号,0-正数,1-负数
  • 指数域E:接下来的第1~8位,存储指数,也即指定小数点的偏移位置
  • 数据域F:剩余第9~31共23位(实际是24位,有1位隐藏位),存储转换成二进制的数据

双精度float64(即其他语言的double。或者其他的如扩展精度等)在float32的基础上增加了8字节,指数位和数据位都得到增加。原理是一样的,不赘述。

指数域E:
因为数据域只存储数据,所以需要指数域来标识小数点从数据域的头部要偏移多少。
由于偏移可以向左,也可以向右,所以8位指数域又被划分为2部分:127255向右偏移,0126向左偏移。
提取指数位算法:将指数位直接转换为1字节的整数,减去127,大于0表示向右偏移,小于0表示向左。
比如E为3时,表示小数点应该向右移动3位。
又如E为-3时,表示向左移动3位。
下面介绍完数据域后,我们再完整的演示几组数据。

数据域F:
存储数据时,总是从第1个1开始,这样可以省略掉开头的1,于是23位数据域可以表示24位的数据。
每次提取数据时,需要固定在前面加一个1。
数据域的数据统一表示为1.xxx的形式,然后通过指数域来标识偏移量。
比如1010.001存储为010001,表示为1.010001,再通过指数位来标识小数点应该往哪边移动多少。

接下来我们通过几组数据示例来理解指数域/数据域的作用。

2.3 用代码打印出浮点数的二进制表示

我用Golang实现了下面的函数,用于打印浮点数的二进制:

func printFloat32(f float32) {
	u32 := *(*uint32)(unsafe.Pointer(&f))
	sBuf := strings.Builder{}

	// 最高位为符号位
	write01(&sBuf, (u32>>31)&1 == 1)
	sBuf.WriteString("-")

	// 中间8位为指数位
	for i := uint32(8); i > 0; i-- {
		write01(&sBuf, (u32>>(i-1+23))&1 == 1)
	}
	sBuf.WriteString("-")

	// 低23位为数值位
	for i := uint32(23); i > 0; i-- {
		write01(&sBuf, (u32>>(i-1))&1 == 1)
	}

	fmt.Printf("浮点数[%.4f]的二进制为[%s]\n", f, sBuf.String())
}

func write01(buf *strings.Builder, flag bool) {
	if flag {
		buf.WriteString("1")
	} else {
		buf.WriteString("0")
	}
}

printFloat32()将f的二进制形式分3部分打印,即符号位s、指数域e、数据域f。
接下来我们来看看10.75在float32下是如何存储的:

printFloat32(10.75)
// 浮点数[10.7500]的二进制为[0-10000010-01011000000000000000000]

浮点数[10.7500]的二进制为[0-10000010-01011000000000000000000]
符号位s为0,表示正数。
数据域为01011,根据前文的说明,前面固定加1.,即1.01011。
指数域10000010为130,减去127为3,表示小数点向右偏移3位,即1010.11。
这正是我们前面演示的10.75的二进制值1010.11。

下面是我随便试的几组数据,有兴趣的同学可以根据前文的方法自己解析下,也可以复制上述代码自己尝试其他的数值。
有个小细节:固定在数据域前面加上1.的方式,不支持数字0。所以低31位全0来默认表示数字0。算上符号位,浮点数能表示+0和-0两个数字0。

浮点数[0.0000]的二进制为[0-00000000-00000000000000000000000]
浮点数[0.2000]的二进制为[0-01111100-10011001100110011001101]
浮点数[0.0010]的二进制为[0-01110101-00000110001001001101111]
浮点数[0.0000]的二进制为[0-00000000-00000000000000000000000]
浮点数[1.0000]的二进制为[0-01111111-00000000000000000000000]

3 解答开篇问题

3.1 小数为什么要叫浮点数?

这个问题其实在介绍IEEE-754标准在计算机里如何表示小数时,已经给出答案了,因为小数点是根据指数域来浮动的,所以叫浮点数。

3.2 浮点数精度和精度丢失,为什么浮点数是近似表示?

关于浮点数的精度问题,我们可以通过分析开篇的1.5-1.3 != 0.2案例来解释。
现在我们将1.5, 1.3, 1.5-1.3, 0.2用前面的打印代码打印出二进制:

浮点数[1.5000]的二进制为[0-01111111-10000000000000000000000]
浮点数[1.3000]的二进制为[0-01111111-01001100110011001100110]
浮点数[0.2000]的二进制为[0-01111100-10011001100110011010000] // 这段是1.5-1.3
浮点数[0.2000]的二进制为[0-01111100-10011001100110011001101] // 这段是0.2

首先,我们关注下第2行,十进制1.3转换成二进制后是1.01001100110011001100110...,注意后面是循环的,实际上这会是个无限循环小数。同样的,0.2转换成二进制,也是无限循环小数。
当出现无限循环时,需要在无法存储的位上截断掉,此时类似于十进制的四舍五入,二进制下采用0舍1入。我们观察1.3,紧随后面的截断位应该是0,所以舍去。但0.2的截断处前面1位应该是0,后面1位是1,于是进1,前面的0变成了1。
这就是为什么浮点数是近似表示,因为十进制转成二进制后算不尽,有可能出现无限循环小数,此时计算机会将数字截断并作0舍1入取近似值。
类似0.1/0.2/0.3/0.4/0.6/0.7/0.8/0.9这几个数字,都是无限循环的,有兴趣的同学可以自己用前文的方法计算一遍。

接下来我们看看浮点数的精度问题。

浮点数[0]的二进制为[0-00000000-00000000000000000000000]
浮点数[0.000000000000000000000000000000000000000000001]的二进制为[0-00000000-00000000000000000000001]
浮点数[16777216]的二进制为[0-10010111-00000000000000000000000]
浮点数[16777217]的二进制为[0-10010111-00000000000000000000000]

上面第2行是float32能表示的最接近0的小数了,再小的话表示不了。此时精度非常高。
但随着数字离0越来越远,即除去符号位,数字越来越大,精度会慢慢丢失,原因是指数位能表示的小数点偏移量最大127。那么浮点数越大,小数点就越往右移,此时存储时右边被截断的数字就越多,精度自然就丢失了。
可以看出第3/4两行,16777216与16777217的浮点数存储居然是一样的,正是开篇第2段代码展示的问题,此时的最小精度已经大于1了。
对于开篇第2段代码的第2个示例,取值math.MaxFloat32时,精度已经远远大于42亿,是不是很神奇。有兴趣的同学可以试着想下,这个时候的精度大概是多少?

开发过程中,极端情况下,一个大数与另一个小数进行操作,容易出现精度丢失严重导致结果误差大的问题。所以一般我们建议不要用单精度float32,而是用双精度float64,增加的8字节让指数位和数据位都增大了,精度自然有所提高,使用更安全

3.3 为什么浮点数不能直接比较?

这个问题跟精度问题是类似的,也是截断引起的。
我们还是以1.5-1.3为例:

浮点数[1.5000]的二进制为[0-01111111-10000000000000000000000]
浮点数[1.3000]的二进制为[0-01111111-01001100110011001100110]
浮点数[0.2000]的二进制为[0-01111100-10011001100110011010000] // 这段是1.5-1.3
浮点数[0.2000]的二进制为[0-01111100-10011001100110011001101] // 这段是0.2

我们将上述浮点的二进制表示转换为二进制小数:

1.5:	1.10000000000000000000000 // 固定在数据域前面添加'1.',下同
1.3:	1.01001100110011001100110 // 无限循环,后面截断了
1.5-1.3:0.00110011001100110011010000 // 注意指数域,小数点左移3位
0.2:	0.00110011001100110011001101

不难算出,第3行+第2行,正好等于第1行(注意遇2则向高位进1位)。
由于1.5和1.3的精度不足,相减后精度没有0.2的精度高,所以上面可以明显看出1.5-1.2和0.2相比,末尾的精度丢失了。
这就是浮点数不能直接比较的原因。

3.4 浮点数的范围,为什么float32的范围远远大于uint32?

在不考虑精度的情况下,float32最大可以表示二进制的1.11111111111111111111111向左移127位(小数点右移127),即十进制的3.40282346638528859811704183484516925440e+38
而uint32最多能移31位。
正是这个无敌的移位操作,让float32能表示的最大数字(或者加上负号表示最小数字)远远超过了uint32,甚至uint64也望尘莫及。
当然,这个数字一般情况下意义不是太大,前面也提到了,精度丢失的有点吓人。
golang的math包内定义了float32等数字的极值,有需要可以使用。

3.5 浮点数为什么不能用位操作?

Golang中直接对浮点数进行位操作,会编译不通过。原因正是浮点数存储格式的特殊性,不像整型每一位都是数据位。
如果你仔细阅读了前面的内容并且确定自己理解了浮点数的原理,可以参考我上面写的打印浮点数二进制的代码,强行对浮点数做位操作。

posted @ 2020-02-15 23:16  Jo_ZSM  阅读(7462)  评论(3编辑  收藏  举报