詳解浮點數,爲何浮點數不能直接比較?

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,整數在計算機內是二進制形式,小數也只能是二進制表示。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
對於一個複雜的小數,上述轉換公式很難直接寫出,因此下面介紹一種方便計算的思路:開發

  • 整數部分,你們很容易想到的編程思路:不斷除以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部分: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,再經過指數位來標識小數點應該往哪邊移動多少。

接下來咱們經過幾組數據示例來理解指數域/數據域的做用。

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中直接對浮點數進行位操做,會編譯不經過。緣由正是浮點數存儲格式的特殊性,不像整型每一位都是數據位。 若是你仔細閱讀了前面的內容而且肯定本身理解了浮點數的原理,能夠參考我上面寫的打印浮點數二進制的代碼,強行對浮點數作位操做。

相關文章
相關標籤/搜索