Golang: 15 張圖帶你深入理解浮點數

大家好,我是站長 polarisxu。

團隊一直保持着分享的習慣,而我卻分享的較少。忘了當時同事分享什麼主題,涉及到浮點數相關知識。於是我決定分享一期關於浮點數的,而且 Go 之父 Rob Pike 說不懂浮點數不配當碼農。。。So?!

本着「要學習就係統透徹的學」這個原則,本文通過圖的方式儘可能詳細的講解浮點數,讓大家能夠對浮點數有一個更深層次的認識。

本文目錄:

0、幾個問題

開始之前請思考如下問題:

如果現在不會,那這篇文章正好可以爲你解惑。

1、什麼是浮點數

我們知道,數學中並沒有浮點數的概念,雖然小數看起來像浮點數,但從不這麼叫。那爲什麼計算機中不叫小數而叫浮點數呢?

因爲資源的限制,數學中的小數無法直接在計算機中準確表示。爲了更好地表示它,計算機科學家們發明了浮點數,這是對小數的近似表示。維基百科中關於浮點數的概念說明如下:

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.

也就是說浮點數是相對於定點數而言的,表示小數點位置是浮動的。比如 7.5 × 10、0.75 × 10² 等表示法,值一樣,但小數點位置不一樣。

具體來說,浮點數是指用符號、尾數、基數和指數這四部分來表示的小數。

2、IEEE754 又是什麼

知道了浮點數的概念,但需要確定一套具體的表示、運算標準。其中最有名的就是 IEEE754 標準。William Kahan 正是因爲浮點數標準化的工作獲得了圖靈獎。

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 標準,這也是目前各大編程語言和硬件使用的標準。

根據上面浮點數的組成,因爲是在計算機中表示浮點數,基數自然是 2,因此 IEEE754 浮點數只關注符號、尾數和指數三部分。

3、小數的二進制和十進制轉換

爲了方便後面的內容順利進行,複習下二進制和十進制的轉換,其中主要涉及到小數的轉換。

二進制轉十進制

和整數轉換一樣,採用各位數值和位權相乘。比如:

(0.101)₂ = 1×2⁻¹ + 0×2⁻² + 0×2⁻³ = (0.625)₁₀

記住小數點後第一位是從 -1 開始即可。

十進制轉二進制

十進制整數轉二進制採用 “除 2 取餘,逆序排列” 法。例如十進制數 11 轉爲二進制:

11/2=5 … 餘1
5/2=2  … 餘1
2/2=1  … 餘0
1/2=0  … 餘1

所以 (11)₁₀ 的二進制是 (1011)₂。

但如果十進制是小數,轉爲二進制小數如何做?採用 “乘 2 取整,順序排列”。例如十進制小數 0.625 轉爲二進制小數:

0.625*2=1.25 … 取整數部分1
0.25*2=0.5   … 取整數部分0
0.5*2=1    … 取整數部分1

順序排列,所以 (0.625)₁₀ = (0.101)₂。

爲了方便大家快速的做轉換,網上有很多這樣的工具。推薦一個我覺得最棒的:https://baseconvert.com/,支持各進制的轉換,還支持浮點數。

4、經典問題:0.1 + 0.2 = 0.30000000000000004

這個問題網上相關的討論很多,甚至有專門的一個網站:https://0.30000000000000004.com/,這個網站上有各門語言的 0.1 + 0.2 的結果。比如 C 語言:

#include <stdio.h>

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)
}

結果都是 0.30000000000000004。

爲什麼會這樣?這要回到 IEEE754 標準關於浮點數的規定。

5、浮點數的 IEEE754 表示

上文提到,浮點數由四個部分構成,那 IEEE754 標準是如何規定它們的存儲方式的呢?

一般地,IEEE754 浮點數有兩種類型:單精度浮點數(float)和雙精度浮點數(double),還有其他的,不常用。單精度浮點數使用 4 字節表示;雙精度浮點數使用 8 字節表示。在 Go 語言中用 float32 和 float64 表示這兩種類型。

符號位不用說,0 表示正數,1 表示負數。着重看指數部分和尾數部分。(基數前文說了,固定是 2,因此不存)

尾數部分

前面提到過,浮點數名稱的由來在於小數點是浮動的。但具體存儲時,需要固定一種形式,這叫做尾數的標準化。IEEE754 規定,在二進制數中,通過移位,將小數點前面的值固定爲 1。IEEE754 稱這種形式的浮點數爲規範化浮點數(normal number)。

比如十進制數 0.15625,轉爲二進制是 0.00101。爲了讓第 1 位爲 1,執行邏輯右移 3 位,尾數部分成爲 1.01,因爲右移了 3 位,所以指數部分是 -3。因爲規定第 1 位永遠爲 1,因此可以省略不存,這樣尾數部分多了 1 位,只需存 0100(要記住,這是的數字是小數點後的數字,因此實際是 0.01,轉爲十進制是 0.25 — 沒算未存的小數點前面的 1)。

因此對於規範化浮點數,尾數其實比實際的多 1 位,也就是說單精度的是 24 位,雙精度是 53 位。爲了作區分,IEEE754 稱這種尾數爲 significand。

有規範化浮點數,自然會有非規範化浮點數(denormal number),這會在後文講解。

請牢記,尾數決定了精度,對於單精度浮點數,因爲只有 23 位,而 1<<23 對應十進制是 8388608,因此不能完整表示全部的 7 個十進制位,所以說,單精度浮點數有效小數位最多 7 位;雙精度的有效小數位是 15 位;切記切記,有精度問題!!

指數部分

因爲指數有正、有負,爲了避免使用符號位,同時方便比較、排序,指數部分採用了 The Biased exponent(有偏指數)。IEEE754 規定,2ᵉ⁻¹-1 的值是 0,其中 e 表示指數部分的位數,小於這個值表示負數,大於這個值表示正數。因此,對於單精度浮點數而言, 2⁸⁻¹-1 = 127 是 0;雙精度浮點數,2¹¹⁻¹-1 = 1023 是 0。

沒看懂?舉個栗子。

還是用十進制 0.15625 舉例。上文知道,因爲右移了 3 位,所以指數是 -3。根據 IEEE754 的定義,單精度浮點數情況下,-3 的實際值是 127 - 3 = 124。明白了嗎?127 表示 0,124 就表示 -3 了。而十進制的 124 轉爲二進制就是 1111100。

如果你還不理解,想想這個問題。

如果讓你用撲克牌(A ~ K,也就是 1 ~ 13)來表示支持負數的。怎麼辦?我們會選擇一箇中間的數,比如 7 當做 0,因此 10 就是 +3,4 就是 -3。現在理解了吧!

小結

結合尾數和指數的規定,IEEE754 單精度浮點數,十進制 0.15625 對應的二進制內存表示是:0 01111100 01000000000000000000000。

6、程序確認下 IEEE754 的如上規定

讀到這裏,希望你能堅持下去。爲了進一步加深理解,我畫一張圖和一個確認程序。

一張圖

這張圖是單精度浮點數 0.15625 的內存存儲表示。根據三部分的二進制表示,可以反推出計算該數的十進制表示。作爲練習,十進制的 2.75,用上圖表示的話,各個位置分別都是什麼值呢?

程序確認單精度浮點數的內存表示

使用 Go 語言編寫一個程序,能夠得到一個單精度浮點數的二進制內存表示。比如提供單精度浮點數 0.15625,該程序能夠輸出:0-01111100-01000000000000000000000。

package main

import (
 "fmt"
 "math"
)

func main() {
 var f float32 = 0.15625
 outputFEEE754(f)
}

func outputFEEE754(f float32) {
 // 將該浮點數內存佈局當做 uint32 看待(因爲都佔用 4 字節)
 // 這裏實際上是做強制轉換,內部實現是:return *(*uint32)(unsafe.Pointer(&f))
 buf := math.Float32bits(f)

 // 加上兩處 -,結果一共 34 byte
 var result [34]byte

 // 從低字節開始
 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。

其實上面推薦的那個工具就能夠得到十進制浮點數的二進制內存表示,地址:https://baseconvert.com/ieee-754-floating-point。

另外,在 Java 語言中也有類似的方法:Float.floatToIntBits(),你可以使用 Java 實現上面類似的功能。

6、再看 0.1+0.2 = 0.30000000000000004

有了上面的知識,我們回過頭看看這個經典的問題。(討論單精度的情況,因此實際是 0.1+0.2 = 0.300000004)

出錯的原因

出現這種情況的根本原因是,有些十進制小數無法轉換爲二進制數。如下圖:

在小數點後 4 位時,連續的二進制數,對應的十進制數卻是不連續的,因此只能增加位數來儘可能近似的表示。

0.1 和 0.2 是如何表示的?

根據前面的講解,十進制 0.1 轉爲二進制小數,得到的是 0.0001100… (重複 1100)這樣一個循環二進制小數,使用 IEEE754 表示如下圖:

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

7、特殊值

耐心的讀者看到這裏,你真的很棒!但還沒完哦,繼續加油!

單精度浮點數的最大值

講解下一個知識點之前,請思考本文開始的一個問題:單精度浮點數的最大值是多少?

根據前面學到的知識,我們很容易想到它的最大值的內存應該表示是這樣的。

即:01111111111111111111111111111111。然而我們把這個值填入 https://baseconvert.com/ieee-754-floating-point 中,發現結果是這樣的:

什麼?NaN 是個什麼鬼?!我就是按照你上面講過的思考的。。。

別急,因爲凡是都有特殊。現在就講講浮點數中的特殊值。

特殊值 infinity(無窮)

當指數位全是 1,尾數位全是 0 時,這樣的浮點數表示無窮。根據符號位,有正無窮和負無窮(+infinity 和 -infinity)。爲什麼需要無窮?因爲計算機資源的限制,沒法表示所有的數,當一個數超過了浮點數的表示範圍時,就可以用 infinity 來表示。而數學中也有無窮的概念。

在 Go 語言中,通過 math 包的 func Inf(sign int) float64 函數可以獲取到正負無窮。

在 Java 語言中,通過 Float 或 Double 類中的常量可以獲得:Float.POSITIVE_INFINITY、Float.NEGATIVE_INFINITY。

具體表示可以定義一個常量,比如:

正無窮:0x7FF0000000000000,負無窮:0xFFF0000000000000

和上面浮點數內存位模型強轉 int 類似,這個執行相反操作(類似 Float64frombits 這樣的函數),就得到了這個特殊的浮點值。可以看 Go 語言 math 標準庫相應函數的實現。

特殊值 NaN

NaN 是 not-a-number 的縮寫,即不是一個數。爲什麼需要它?例如,當對 -1 進行開根號時,浮點數不知道如何進行計算,就會使用 NaN,表示不是一個數。

NaN 的具體內存表示是:指數位全是 1,尾數位不全是 0。

和 infinity 類似,Go 和 Java 都定義了相應的函數或常量。

小結

現在清楚上面單精度浮點數最大值是不對的了吧,它是一個 NaN。畫一張圖,方便你更清晰的記住這些特殊值。

所以單精度浮點數的最大值應該能確認了,即:0 11111110 11111111111111111111111。

8、非規範化浮點數

接着用問題的方式繼續:單精度浮點數的最小值是多少(正數)?

根據前面的知識,我們會得到這樣的最小值:0 00000000 00000000000000000000001。根據前面規範化浮點數的規定,我們知曉該值是:2⁻¹²⁷×(1+2⁻²³)。

然而,最小值的內存表示沒錯,但算出來的結果是錯的。(額頭冒汗沒?怎麼又錯了~)

爲了避免兩個小浮點數相減結果是 0(也就是規範化浮點數無法表示)這樣情況出現,同時根據規範化浮點數的定義,因爲尾數部分有一個省略的前導 1,因此無法表示 0。所以,IEEE754 規定了另外一種浮點數:

當指數位全是 0,尾數部分不全爲 0,尾數部分沒有省略的前導 1,同時指數部分的偏移值比規範形式的偏移值小 1,即單精度是 -126,雙精度是 -2046。這種形式的浮點數叫非規範化浮點數(denormal number)。

因此單精度浮點數的最小值(正數)如下圖:

有了非規範化浮點數,IEEE754 就可以表示 0 了,但會存在 +0 和 -0:即所有位全是 0 時是 +0;符號位是 1,其他位是 0 時是 -0。

9、IEEE754 浮點數分類小結

至此,浮點數相關的知識就介紹差不多了。爲了讓大家對整體再有一個更好的掌握,對浮點數的分類進行一些總結。

從上面的講解,IEEE754 浮點數,指數是關鍵,根據指數,將其分爲:特殊值、非規範化浮點數和規範化浮點數。

從上圖規範化和非規範化浮點數的表示範圍可以看出,兩種類型的表示是具有連續性的。這也就是爲什麼非規範化浮點數指數規定爲比規範形式的偏移值小 1(即單精度爲 -126,雙精度爲 -2046)。

在數軸上,浮點數的分佈:

10、總結

《深入理解計算機系統》這本書在講解浮點數時說:許多程序員認爲浮點數沒意思,往壞了說,深奧難懂。經過本文的四千多字圖文並茂的方式講解,如果你認真看完了,我相信你一定掌握了浮點數。

此外,還有其他一些知識點,比如浮點數的運算、不滿足結合律、四舍但五不一定入等,有興趣的可以查閱相關資料。

現在是時候回過頭來看看開始的題目了,你都會了嗎?

最後,建議你結合你熟悉的語言更進一步補充相關知識。比如 Go 語言的 math 標準庫;Java 的 java.lang.Float/Double 等包。

參考資料或相關鏈接

福利

我爲大家整理了一份從入門到進階的 Go 學習資料禮包,包含學習建議:入門看什麼,進階看什麼。關注公衆號 「polarisxu」,回覆 ebook 獲取;還可以回覆「進羣」,和數萬 Gopher 交流學習。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DGP4rENdy-N1VyZLS_uV4g