15 張圖帶你深入理解浮點數(shù)
點擊上方藍色“polarisxu”關(guān)注我,設(shè)個星標(biāo),不會讓你失望
大家好,我是站長 polarisxu。
團隊一直保持著分享的習(xí)慣,而我卻分享的較少。忘了當(dāng)時同事分享什么主題,涉及到浮點數(shù)相關(guān)知識。于是我決定分享一期關(guān)于浮點數(shù)的,而且 Go 之父 Rob Pike 說不懂浮點數(shù)不配當(dāng)碼農(nóng)。。。So?!

本著「要學(xué)習(xí)就系統(tǒng)透徹的學(xué)」這個原則,本文通過圖的方式盡可能詳細的講解浮點數(shù),讓大家能夠?qū)Ω↑c數(shù)有一個更深層次的認識。
本文目錄:

0、幾個問題
開始之前請思考如下問題:
二進制 0.1,用十進制表示的話是多少?十進制的 0.1,用二進制表示又是多少? 為什么 0.1 + 0.2 = 0.30000000000000004? 單精度和雙精度浮點數(shù)的有效小數(shù)位分別是多少? 單精度浮點數(shù)能表示的范圍是什么? 浮點數(shù)為什么會存在 -0?infinity 和 NaN 又是怎么表示的?
如果現(xiàn)在不會,那這篇文章正好可以為你解惑。
1、什么是浮點數(shù)
我們知道,數(shù)學(xué)中并沒有浮點數(shù)的概念,雖然小數(shù)看起來像浮點數(shù),但從不這么叫。那為什么計算機中不叫小數(shù)而叫浮點數(shù)呢?
因為資源的限制,數(shù)學(xué)中的小數(shù)無法直接在計算機中準(zhǔn)確表示。為了更好地表示它,計算機科學(xué)家們發(fā)明了浮點數(shù),這是對小數(shù)的近似表示。維基百科中關(guān)于浮點數(shù)的概念說明如下:
The term floating point refers to the fact that a number's radix point (decimal point, or, more commonly in computers, binary point) can float; that is, it can be placed anywhere relative to the significant digits of the number.
也就是說浮點數(shù)是相對于定點數(shù)而言的,表示小數(shù)點位置是浮動的。比如 7.5 × 10、0.75 × 102 等表示法,值一樣,但小數(shù)點位置不一樣。
具體來說,浮點數(shù)是指用符號、尾數(shù)、基數(shù)和指數(shù)這四部分來表示的小數(shù)。

2、IEEE754 又是什么
知道了浮點數(shù)的概念,但需要確定一套具體的表示、運算標(biāo)準(zhǔn)。其中最有名的就是 IEEE754 標(biāo)準(zhǔn)。William Kahan 正是因為浮點數(shù)標(biāo)準(zhǔn)化的工作獲得了圖靈獎。
The IEEE Standard for Floating-Point Arithmetic (IEEE 754) is a technical standard for floating-point arithmetic established in 1985 by the Institute of Electrical and Electronics Engineers (IEEE). The standard addressed many problems found in the diverse floating-point implementations that made them difficult to use reliably and portably. Many hardware floating-point units use the IEEE 754 standard.
本文的討論都基于 IEEE754 標(biāo)準(zhǔn),這也是目前各大編程語言和硬件使用的標(biāo)準(zhǔn)。
根據(jù)上面浮點數(shù)的組成,因為是在計算機中表示浮點數(shù),基數(shù)自然是 2,因此 IEEE754 浮點數(shù)只關(guān)注符號、尾數(shù)和指數(shù)三部分。
3、小數(shù)的二進制和十進制轉(zhuǎn)換
為了方便后面的內(nèi)容順利進行,復(fù)習(xí)下二進制和十進制的轉(zhuǎn)換,其中主要涉及到小數(shù)的轉(zhuǎn)換。
二進制轉(zhuǎn)十進制
和整數(shù)轉(zhuǎn)換一樣,采用各位數(shù)值和位權(quán)相乘。比如:
(0.101)? = 1×2?1 + 0×2?2 + 0×2?3 = (0.625)??
記住小數(shù)點后第一位是從 -1 開始即可。
十進制轉(zhuǎn)二進制
十進制整數(shù)轉(zhuǎn)二進制采用“除 2 取余,逆序排列”法。例如十進制數(shù) 11 轉(zhuǎn)為二進制:
11/2=5?…?余1
5/2=2??…?余1
2/2=1??…?余0
1/2=0??…?余1
所以 (11)?? 的二進制是 (1011)?。
但如果十進制是小數(shù),轉(zhuǎn)為二進制小數(shù)如何做?采用“乘 2 取整,順序排列”。例如十進制小數(shù) 0.625 轉(zhuǎn)為二進制小數(shù):
0.625*2=1.25?…?取整數(shù)部分1
0.25*2=0.5???…?取整數(shù)部分0
0.5*2=1????…?取整數(shù)部分1
順序排列,所以 (0.625)?? = (0.101)?。
為了方便大家快速的做轉(zhuǎn)換,網(wǎng)上有很多這樣的工具。推薦一個我覺得最棒的:https://baseconvert.com/,支持各進制的轉(zhuǎn)換,還支持浮點數(shù)。
4、經(jīng)典問題:0.1 + 0.2 = 0.30000000000000004
這個問題網(wǎng)上相關(guān)的討論很多,甚至有專門的一個網(wǎng)站:https://0.30000000000000004.com/,這個網(wǎng)站上有各門語言的 0.1 + 0.2 的結(jié)果。比如 C 語言:
#include?
int?main(int?argc,?char**?argv)?{
??printf("%.17f\n",?.1?+?.2);
??return?0;
}
Go 語言:
package?main
import?(
?"fmt"
)
func?main()?{
?var?a,?b?float64?=?0.1,?0.2
?fmt.Println(a?+?b)
}
結(jié)果都是 0.30000000000000004。
為什么會這樣?這要回到 IEEE754 標(biāo)準(zhǔn)關(guān)于浮點數(shù)的規(guī)定。
5、浮點數(shù)的 IEEE754 表示
上文提到,浮點數(shù)由四個部分構(gòu)成,那 IEEE754 標(biāo)準(zhǔn)是如何規(guī)定它們的存儲方式的呢?
一般地,IEEE754 浮點數(shù)有兩種類型:單精度浮點數(shù)(float)和雙精度浮點數(shù)(double),還有其他的,不常用。單精度浮點數(shù)使用 4 字節(jié)表示;雙精度浮點數(shù)使用 8 字節(jié)表示。在 Go 語言中用 float32 和 float64 表示這兩種類型。

符號位不用說,0 表示正數(shù),1 表示負數(shù)。著重看指數(shù)部分和尾數(shù)部分。(基數(shù)前文說了,固定是 2,因此不存)
尾數(shù)部分
前面提到過,浮點數(shù)名稱的由來在于小數(shù)點是浮動的。但具體存儲時,需要固定一種形式,這叫做尾數(shù)的標(biāo)準(zhǔn)化。IEEE754 規(guī)定,在二進制數(shù)中,通過移位,將小數(shù)點前面的值固定為 1。IEEE754 稱這種形式的浮點數(shù)為規(guī)范化浮點數(shù)(normal number)。
比如十進制數(shù) 0.15625,轉(zhuǎn)為二進制是 0.00101。為了讓第 1 位為 1,執(zhí)行邏輯右移 3 位,尾數(shù)部分成為 1.01,因為右移了 3 位,所以指數(shù)部分是 -3。因為規(guī)定第 1 位永遠為 1,因此可以省略不存,這樣尾數(shù)部分多了 1 位,只需存 0100(要記住,這是的數(shù)字是小數(shù)點后的數(shù)字,因此實際是 0.01,轉(zhuǎn)為十進制是 0.25 — 沒算未存的小數(shù)點前面的 1)。
因此對于規(guī)范化浮點數(shù),尾數(shù)其實比實際的多 1 位,也就是說單精度的是 24 位,雙精度是 53 位。為了作區(qū)分,IEEE754 稱這種尾數(shù)為 significand。
有規(guī)范化浮點數(shù),自然會有非規(guī)范化浮點數(shù)(denormal number),這會在后文講解。
請牢記,尾數(shù)決定了精度,對于單精度浮點數(shù),因為只有 23 位,而 1<<23 對應(yīng)十進制是 8388608,因此不能完整表示全部的 7 個十進制位,所以說,單精度浮點數(shù)有效小數(shù)位最多 7 位;雙精度的有效小數(shù)位是 15 位;切記切記,有精度問題!!
指數(shù)部分
因為指數(shù)有正、有負,為了避免使用符號位,同時方便比較、排序,指數(shù)部分采用了 The Biased exponent(有偏指數(shù))。IEEE754 規(guī)定,2??1-1 的值是 0,其中 e 表示指數(shù)部分的位數(shù),小于這個值表示負數(shù),大于這個值表示正數(shù)。因此,對于單精度浮點數(shù)而言, 2??1-1 = 127 是 0;雙精度浮點數(shù),211?1-1 = 1023 是 0。
沒看懂?舉個栗子。
還是用十進制 0.15625 舉例。上文知道,因為右移了 3 位,所以指數(shù)是 -3。根據(jù) IEEE754 的定義,單精度浮點數(shù)情況下,-3 的實際值是 127 - 3 = 124。明白了嗎?127 表示 0,124 就表示 -3 了。而十進制的 124 轉(zhuǎn)為二進制就是 1111100。
如果你還不理解,想想這個問題。
如果讓你用撲克牌(A ~ K,也就是 1 ~ 13)來表示支持負數(shù)的。怎么辦?我們會選擇一個中間的數(shù),比如 7 當(dāng)做 0,因此 10 就是 +3,4 就是 -3。現(xiàn)在理解了吧!
小結(jié)
結(jié)合尾數(shù)和指數(shù)的規(guī)定,IEEE754 單精度浮點數(shù),十進制 0.15625 對應(yīng)的二進制內(nèi)存表示是:0 01111100 01000000000000000000000。
6、程序確認下 IEEE754 的如上規(guī)定
讀到這里,希望你能堅持下去。為了進一步加深理解,我畫一張圖和一個確認程序。
一張圖

這張圖是單精度浮點數(shù) 0.15625 的內(nèi)存存儲表示。根據(jù)三部分的二進制表示,可以反推出計算該數(shù)的十進制表示。作為練習(xí),十進制的 2.75,用上圖表示的話,各個位置分別都是什么值呢?
程序確認單精度浮點數(shù)的內(nèi)存表示
使用 Go 語言編寫一個程序,能夠得到一個單精度浮點數(shù)的二進制內(nèi)存表示。比如提供單精度浮點數(shù) 0.15625,該程序能夠輸出:0-01111100-01000000000000000000000。
package?main
import?(
?"fmt"
?"math"
)
func?main()?{
?var?f?float32?=?0.15625
?outputFEEE754(f)
}
func?outputFEEE754(f?float32)?{
?//?將該浮點數(shù)內(nèi)存布局當(dāng)做?uint32?看待(因為都占用?4?字節(jié))
?//?這里實際上是做強制轉(zhuǎn)換,內(nèi)部實現(xiàn)是:return *(*uint32)(unsafe.Pointer(&f))
?buf?:=?math.Float32bits(f)
?//?加上兩處?-,結(jié)果一共?34?byte
?var?result?[34]byte
?//?從低字節(jié)開始
?for?i?:=?33;?i?>=?0;?i--?{
??if?i?==?1?||?i?==?10?{
???result[i]?=?'-'
??}?else?{
???if?buf%2?==?1?{
????result[i]?=?'1'
???}?else?{
????result[i]?=?'0'
???}
???buf?/=?2
??}
?}
?fmt.Printf("%s\n",?result)
}
//?output:?0-01111100-01000000000000000000000
你可以使用上述程序,驗證下 2.75,看看你做對沒有!提供了一個在線可運行版本:https://play.studygolang.com/p/pg0QNQtBHYx。
其實上面推薦的那個工具就能夠得到十進制浮點數(shù)的二進制內(nèi)存表示,地址:https://baseconvert.com/ieee-754-floating-point。

另外,在 Java 語言中也有類似的方法:Float.floatToIntBits(),你可以使用 Java 實現(xiàn)上面類似的功能。
6、再看 0.1+0.2 = 0.30000000000000004
有了上面的知識,我們回過頭看看這個經(jīng)典的問題。(討論單精度的情況,因此實際是 0.1+0.2 = 0.300000004)
出錯的原因
出現(xiàn)這種情況的根本原因是,有些十進制小數(shù)無法轉(zhuǎn)換為二進制數(shù)。如下圖:

在小數(shù)點后 4 位時,連續(xù)的二進制數(shù),對應(yīng)的十進制數(shù)卻是不連續(xù)的,因此只能增加位數(shù)來盡可能近似的表示。
0.1 和 0.2 是如何表示的?
根據(jù)前面的講解,十進制 0.1 轉(zhuǎn)為二進制小數(shù),得到的是 0.0001100… (重復(fù)1100)這樣一個循環(huán)二進制小數(shù),使用 IEEE754 表示如下圖:

同樣的方法,0.2 用單精度浮點數(shù)表示是:0.20000000298023223876953125。所以,0.1 + 0.2 的結(jié)果是:0.300000004470348358154296875。

7、特殊值
耐心的讀者看到這里,你真的很棒!但還沒完哦,繼續(xù)加油!
單精度浮點數(shù)的最大值
講解下一個知識點之前,請思考本文開始的一個問題:單精度浮點數(shù)的最大值是多少?
根據(jù)前面學(xué)到的知識,我們很容易想到它的最大值的內(nèi)存應(yīng)該表示是這樣的。

即:01111111111111111111111111111111。然而我們把這個值填入 https://baseconvert.com/ieee-754-floating-point 中,發(fā)現(xiàn)結(jié)果是這樣的:

什么?NaN 是個什么鬼?!我就是按照你上面講過的思考的。。。
別急,因為凡是都有特殊。現(xiàn)在就講講浮點數(shù)中的特殊值。
特殊值 infinity(無窮)
當(dāng)指數(shù)位全是 1,尾數(shù)位全是 0 時,這樣的浮點數(shù)表示無窮。根據(jù)符號位,有正無窮和負無窮(+infinity 和 -infinity)。為什么需要無窮?因為計算機資源的限制,沒法表示所有的數(shù),當(dāng)一個數(shù)超過了浮點數(shù)的表示范圍時,就可以用 infinity 來表示。而數(shù)學(xué)中也有無窮的概念。
在 Go 語言中,通過 math 包的 func Inf(sign int) float64 函數(shù)可以獲取到正負無窮。
在 Java 語言中,通過 Float 或 Double 類中的常量可以獲得:Float.POSITIVE_INFINITY、Float.NEGATIVE_INFINITY。
具體表示可以定義一個常量,比如:
正無窮:0x7FF0000000000000,負無窮:0xFFF0000000000000
和上面浮點數(shù)內(nèi)存位模型強轉(zhuǎn) int 類似,這個執(zhí)行相反操作(類似 Float64frombits 這樣的函數(shù)),就得到了這個特殊的浮點值。可以看 Go 語言 math 標(biāo)準(zhǔn)庫相應(yīng)函數(shù)的實現(xiàn)。
特殊值 NaN
NaN 是 not-a-number 的縮寫,即不是一個數(shù)。為什么需要它?例如,當(dāng)對 -1 進行開根號時,浮點數(shù)不知道如何進行計算,就會使用 NaN,表示不是一個數(shù)。
NaN 的具體內(nèi)存表示是:指數(shù)位全是 1,尾數(shù)位不全是 0。
和 infinity 類似,Go 和 Java 都定義了相應(yīng)的函數(shù)或常量。
小結(jié)
現(xiàn)在清楚上面單精度浮點數(shù)最大值是不對的了吧,它是一個 NaN。畫一張圖,方便你更清晰的記住這些特殊值。

所以單精度浮點數(shù)的最大值應(yīng)該能確認了,即:0 11111110 11111111111111111111111。

8、非規(guī)范化浮點數(shù)
接著用問題的方式繼續(xù):單精度浮點數(shù)的最小值是多少(正數(shù))?
根據(jù)前面的知識,我們會得到這樣的最小值:0 00000000 00000000000000000000001。根據(jù)前面規(guī)范化浮點數(shù)的規(guī)定,我們知曉該值是:2?12?×(1+2?23)。
然而,最小值的內(nèi)存表示沒錯,但算出來的結(jié)果是錯的。(額頭冒汗沒?怎么又錯了~)
為了避免兩個小浮點數(shù)相減結(jié)果是 0(也就是規(guī)范化浮點數(shù)無法表示)這樣情況出現(xiàn),同時根據(jù)規(guī)范化浮點數(shù)的定義,因為尾數(shù)部分有一個省略的前導(dǎo) 1,因此無法表示 0。所以,IEEE754 規(guī)定了另外一種浮點數(shù):
當(dāng)指數(shù)位全是 0,尾數(shù)部分不全為 0,尾數(shù)部分沒有省略的前導(dǎo) 1,同時指數(shù)部分的偏移值比規(guī)范形式的偏移值小 1,即單精度是 -126,雙精度是 -2046。這種形式的浮點數(shù)叫非規(guī)范化浮點數(shù)(denormal number)。
因此單精度浮點數(shù)的最小值(正數(shù))如下圖:

有了非規(guī)范化浮點數(shù),IEEE754 就可以表示 0 了,但會存在 +0 和 -0:即所有位全是 0 時是 +0;符號位是 1,其他位是 0 時是 -0。
9、IEEE754 浮點數(shù)分類小結(jié)
至此,浮點數(shù)相關(guān)的知識就介紹差不多了。為了讓大家對整體再有一個更好的掌握,對浮點數(shù)的分類進行一些總結(jié)。
從上面的講解,IEEE754 浮點數(shù),指數(shù)是關(guān)鍵,根據(jù)指數(shù),將其分為:特殊值、非規(guī)范化浮點數(shù)和規(guī)范化浮點數(shù)。

從上圖規(guī)范化和非規(guī)范化浮點數(shù)的表示范圍可以看出,兩種類型的表示是具有連續(xù)性的。這也就是為什么非規(guī)范化浮點數(shù)指數(shù)規(guī)定為比規(guī)范形式的偏移值小 1(即單精度為 -126,雙精度為 -2046)。
在數(shù)軸上,浮點數(shù)的分布:

10、總結(jié)
《深入理解計算機系統(tǒng)》這本書在講解浮點數(shù)時說:許多程序員認為浮點數(shù)沒意思,往壞了說,深奧難懂。經(jīng)過本文的四千多字圖文并茂的方式講解,如果你認真看完了,我相信你一定掌握了浮點數(shù)。
此外,還有其他一些知識點,比如浮點數(shù)的運算、不滿足結(jié)合律、四舍但五不一定入等,有興趣的可以查閱相關(guān)資料。
現(xiàn)在是時候回過頭來看看開始的題目了,你都會了嗎?
最后,建議你結(jié)合你熟悉的語言更進一步補充相關(guān)知識。比如 Go 語言的 math 標(biāo)準(zhǔn)庫;Java 的 java.lang.Float/Double 等包。
參考資料或相關(guān)鏈接
https://floating-point-gui.de/ https://www.geeksforgeeks.org/ieee-standard-754-floating-point-numbers/ https://baseconvert.com/ 這個交互式工具,很不錯:http://evanw.github.io/float-toy/ https://bartaz.github.io/ieee754-visualization/ 柴大:https://mp.weixin.qq.com/s/0lCte3UD5qYcaBnebwnYrQ 左神:https://mp.weixin.qq.com/s/QsEe34pcimNdqCb99h44cQ 圖書《程序是怎樣跑起來的》 推薦閱讀
1、深入揭秘前端路由本質(zhì),手寫 mini-router
3、尤大 3 天前發(fā)在 GitHub 上的 vue-lit 是啥?
如果覺得文章不錯,幫忙點個在看唄
