目录golang
昨天与靓神聊到浮点数精度丢失的问题,因而今天写一篇文档来详细描述现代计算机的浮点数存储方式,进而解答相关的一些问题:算法
首先咱们来看下面这段代码,请问输出结果是什么:编程
func main() { a, b := 1.5, 1.3 fmt.Println(a-b == 0.2) fmt.Println(a-b > 0.2) }
若是上面的示例没惊奇到你,那么咱们再看这个示例:安全
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大 } }
咱们都知道计算机只识别0和1,整数在计算机内是二进制形式,小数也只能是二进制表示。ui
一个小数能够分为3部分:整数部分、小数点、小数部分。code
以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
对于一个复杂的小数,上述转换公式很难直接写出,因此下面介绍一种方便计算的思路:开发
以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标准,这个标准规定了浮点数的二进制表示格式、操做方式、舍入模式及异常处理等。
前面介绍了浮点数的二进制表示,而IEEE-754咱们主要关注点能够集中在它在存储浮点数二进制时是怎么处理小数点的。
以golang的单精度float32为例,IEEE-754标准的float32以下:
s-eeee eeee-ffff ffff ffff ffff ffff fff
一个32位的单精度浮点数的32个bit位被划分为定长的3个组成部分:
双精度float64(即其余语言的double。或者其余的如扩展精度等)在float32的基础上增长了8字节,指数位和数据位都获得增长。原理是同样的,不赘述。
指数域E:
由于数据域只存储数据,因此须要指数域来标识小数点从数据域的头部要偏移多少。
因为偏移能够向左,也能够向右,因此8位指数域又被划分为2部分:127~255向右偏移,0~126向左偏移。
提取指数位算法:将指数位直接转换为1字节的整数,减去127,大于0表示向右偏移,小于0表示向左。
好比E为3时,表示小数点应该向右移动3位。
又如E为-3时,表示向左移动3位。
下面介绍完数据域后,咱们再完整的演示几组数据。
数据域F:
存储数据时,老是从第1个1开始,这样能够省略掉开头的1,因而23位数据域能够表示24位的数据。
每次提取数据时,须要固定在前面加一个1。
数据域的数据统一表示为1.xxx的形式,而后经过指数域来标识偏移量。
好比1010.001存储为010001,表示为1.010001,再经过指数位来标识小数点应该往哪边移动多少。
接下来咱们经过几组数据示例来理解指数域/数据域的做用。
我用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]
这个问题其实在介绍IEEE-754标准在计算机里如何表示小数时,已经给出答案了,由于小数点是根据指数域来浮动的,因此叫浮点数。
关于浮点数的精度问题,咱们能够经过分析开篇的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字节让指数位和数据位都增大了,精度天然有所提升,使用更安全
这个问题跟精度问题是相似的,也是截断引发的。
咱们仍是以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相比,末尾的精度丢失了。
这就是浮点数不能直接比较的缘由。
在不考虑精度的状况下,float32最大能够表示二进制的1.11111111111111111111111向左移127位(小数点右移127),即十进制的3.40282346638528859811704183484516925440e+38
而uint32最多能移31位。
正是这个无敌的移位操做,让float32能表示的最大数字(或者加上负号表示最小数字)远远超过了uint32,甚至uint64也可望不可即。
固然,这个数字通常状况下意义不是太大,前面也提到了,精度丢失的有点吓人。
golang的math包内定义了float32等数字的极值,有须要可使用。
Golang中直接对浮点数进行位操做,会编译不经过。缘由正是浮点数存储格式的特殊性,不像整型每一位都是数据位。 若是你仔细阅读了前面的内容而且肯定本身理解了浮点数的原理,能够参考我上面写的打印浮点数二进制的代码,强行对浮点数作位操做。