詳解浮點數,為什么浮點數不能直接比較?


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中直接對浮點數進行位操作,會編譯不通過。原因正是浮點數存儲格式的特殊性,不像整型每一位都是數據位。
如果你仔細閱讀了前面的內容並且確定自己理解了浮點數的原理,可以參考我上面寫的打印浮點數二進制的代碼,強行對浮點數做位操作。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM