精度損失指北:1 - 0.9 爲何不等於 0.1

考考你,System.out.println(1.0f - 0.9f)結果是什麼?
3...
2...
1...html

揭曉答案:0.100000024前端

若是好奇爲何答案不是0.1,就往下看⬇️java

計算機科學中是怎麼表示浮點數的?

咱們都知道,在數學中,採用科學計數法來近似表示一個極大或極小且位數較多的數。科學計數法的表示形如:android

a x 10^n. 其中1≦ |a| ≦ 10,a 稱爲有效數字markdown

從數學世界的科學計數法映射到計算機世界的浮點數時,考慮到內存硬件設備的實現以及數制的變化(從十進制改成二進制),表現出來的形式略有不一樣。oop

其中,十進制中的指數變成了「階碼」,有效數字被改爲了「尾數」,再加上計算機二進制數制下特有的符號位,就構成了計算機科學計數法中的三要素:性能

  • 符號位
  • 階碼位
  • 尾數位

這麼說可能不夠直觀,咱們以單精度浮點數爲例,它佔有4個字節,總共32位,三要素表現以下:編碼

咱們一個一個來看:spa

  1. 符號位code

    佔據最高的二進制位,0表示正數,1表示負數。

  2. 階碼位

    符號位右側8位用來表示指數,首先明確一點,在計算機世界主流的IEEE754標準中,階碼位存儲的是指數對應的移碼

    根據百度百科對於移碼的定義:

    移碼(又叫增碼)是符號位取反的補碼,通常用指數的移碼減去1來作浮點數階碼,引入的目的是爲了保證浮點數的機器零爲全0。

    得[X]移 = x + 2^n-1(n爲x的二進制位數,含符號位置,在階碼的表示中,n = 8)

  3. 尾數位

    上面說了,尾數位表示的是浮點數的有效數字。一個符合規格化的尾數位最高位必定是1(你品,你細細品...),因此爲了節約存儲空間,就將這個最高位1省略了。所以,尾數位真正佔用的尾數是24位,表現出來23位。

介紹完三要素,咱們舉幾個簡單的例子來詳細說明一下以上的知識點,好比,十進制數字「8.0」在計算機世界上的表示:

看到這裏,你能很輕易的一隅三反:

那我考考你,十進制數字「0.9」怎麼表示呢?

揭曉答案:

舉這個例子只是爲了告訴你:

某些浮點數沒法在有限的二進制科學計數法中精確表示。

浮點數的加減運算

考考你,咱們上小學時是怎麼運算小數的加減運算的(把大象關進冰箱,統共分幾步)?

1.計算小數加、減法,先把各數的小數點對齊(也就是把相同數位上的數對齊)

2.再按照整數加、減法的法則進行計算,最後在得數裏對齊橫線上的小數點點上小數點。

從上面咱們也不難看出,小數加減法運算最重要的一個步驟就是小數點對齊。一樣,對於浮點數的加減運算,「對齊小數點」也是很重要的環節。映射到科學計數法表示下的浮點數計算,就是要確保指數同樣,這步操做有個專業術語,叫做對階操做

首先求出兩浮點數階碼的差,即⊿E=Ex-Ey,將小階碼加上⊿E,使之與大階碼相等,同時將小階碼對應的浮點數的尾數右移相應位數,以保證該浮點數的值不變。

  • 對階的原則是小階對大階,之因此這樣作是由於若大階對小階,則尾數的數值部分的高位需移出,而小階對大階移出的是尾數的數值部分的低位,這樣損失的精度更小。
  • 若⊿E=0,說明兩浮點數的階碼已經相同,無需再作對階操做了。

在進行對階操做前,會首先檢查參與運算的兩個數是否有值爲0的。由於浮點數的運算很複雜,在Google的《Performance tips》中也有一條tip:

Avoid using floating-point

當其中一個數爲0時,將直接返回參與計算的另一個值做爲結果。

對階操做完成後將尾數進行相應的運算(加法直接求和,若是是負數就先轉換爲補碼再進行求和運算),與十進制運算相似。

通過上面的步驟得出的結果若是仍然知足

a x 2^n. 其中1≦ |a| ≦ 2的話就無需處理,若是不知足的話,就須要移動尾數的位數(左移或者右移)使其知足該形式,這一步一樣會損失精度,這一步稱之爲結果規格化,尾數右移就叫右規,左移就叫左規。

爲了彌補對階操做以及結果規格化過程當中的精度損失,會將移出的這部分數據保存起來,這就是保護位,等到結果規格化後再根據保護位進行舍入處理。

總結下來,大概就是如下幾個流程:

理清楚了以上的概念,咱們再來研究下標題提出的問題:

1 - 0.9 ≠ 0.1, 這是爲何?

1-0.9怎麼就不等於0.1了?

首先,咱們要清楚,計算機中的減法每每是轉換成加法來運算的。好比 1.0 - 0.9,就等價於 1.0 + (-0.9)。

咱們首先把1.0 與 -0.9 的二進制編碼寫出來:

上文寫到過,尾數位的最高位隱藏了一位1(沒記住的好好檢討下),因此1.0的實際尾數爲:

1000 - 0000 - 0000 - 0000 - 0000 - 0000

-0.9的實際尾數爲:

1110 - 0110 - 0110 - 0110 - 0110 - 0110

接下來咱們按照零值檢測 -> 對階操做 -> 尾數求和 -> 結果規格化 -> 結果舍入來操做一下:

零值檢測

很明顯,兩個數大小都不爲0,該步驟跳過。

對階操做

1.0的階碼爲127,-0.9的階碼爲126,經過比較咱們可以發現,-0.9的尾數的補碼須要向右移動,高位補1,使其階碼變爲127,達到「小數點對齊的效果」,-0.9移動後的尾數位的補碼爲:

1000 - 1100 - 1100 - 1100 -1100 - 1101

尾數求和

將1.0與-0.9的尾數爲轉換成補碼,而後按位相加(對階操做完成後,階碼位再也不參與運算,只有尾數位與符號位參與運算):

獲得尾數位的運算結果爲:

0000 - 1100 - 1100 - 1100 - 1100 -1101

結果規格化

尾數求和後的操做並不合乎要求(尾數的最高位必須爲1,不明白爲何的一樣好好檢討下),因此這裏咱們須要將結果左移4位,同時階碼減4來進行結果規格化

這樣一頓操做後,階碼等於123(對應的二進制爲 1111011),尾數爲

1100 - 1100 - 1100 - 1100 - 1101 - 0000

再隱藏其尾數的最高位,進而變爲:

100 - 1100 - 1100 - 1100 - 1101 - 0000

最終結果

最終,1.0 - 0.9的運算結果爲:

最後,咱們就獲得了一個符號位爲0、階碼爲011110十一、尾數位爲100 - 1100 - 1100 - 1100 - 1101 - 0000,對應的十進制表示爲0.100000024。

精度損失帶來的不良後果

既然浮點數使用時會產生精度損失的問題,那麼會給咱們的平常開發形成什麼影響呢?

使用浮點數大小判斷控制某些業務流程時每每產生不可預期的行爲。

想象一下這樣的場景,電商App,提交訂單時須要前端校驗用戶餘額是否足夠支付訂單,若是不夠,就禁用提交訂單按鈕。

若是不瞭解精度損失,咱們很容易寫下這樣的判斷:

btSubmit.setEnabled(balance >= orderAmount)
複製代碼

若是此時餘額或者訂單金額丟失了精度,就可能會出現用戶餘額明明足夠支付訂單卻由於前端錯誤的判斷禁用了提交訂單的按鈕致使用戶沒法提交訂單(別問我怎麼知道這麼詳細的場景,再問自殺)。

如何避免精度損失帶來的不良後果?

避免沒必要要的使用浮點數.

使用浮點數會帶來如下麻煩:性能損耗和精度損失。通常來說,在 Android 設備上,浮點數要比整數慢約 2 倍。因此,若是某個參數不是沒法避免的要使用浮點數類型,更推薦使用整型來替代浮點數類型。

避免不了浮點數使用時,使用雙精度浮點數double來代替float.

首先,在速度方面,floatdouble在如今的硬件上沒有任何區別,在時間和空間的決策上,我相信大部分人都更傾向於使用空間換取時間。同時,得益於更大的存儲空間,雙精度浮點型的精度比單精度浮點型要高很多。

好比上一部分舉到的例子,咱們徹底可使用人民中的分做爲單位,將餘額與訂單金額轉化成以分做單位的整型比較從而避免因精度損失形成的不可預知的不良後果。

總結

綜上所述,計算機中浮點數的精度損失咱們避免不了,可是咱們能夠合理規避掉因爲精度損失所形成的不良後果,好比:控制業務流程時避免使用兩個浮點數的大小做爲判斷依據。

相關文章
相關標籤/搜索