JS中0.1 + 0.2 不等於0.3 ?

例子:

你有沒有發現一個場景,在JS中對十進制數進行了一些算術計算,但它返回了一個奇怪的結果?

比如以下例子:

  • 0.1 + 0.2 期望是等於 0.3 但顯示結果是 0.30000000000000004
  • 6 * 0.1期望是 0.6 但顯示結果是 0.6000000000000001
  • 0.11 + 0.12 期望是 0.23 但顯示結果是 0.22999999999999998
  • 0.1 + 0.7 顯示結果是 0.7999999999999999
  • 0.3 + 0.6 顯示結果是 0.8999999999999999。…… 還有其他一些類似的情況。

從上面可以看到,0.1+0.2!==0.3並且0.1+0.2的結果是0.30000000000000004。爲什麼會出現這樣的結果呢?

下文就讓我們一塊來探索一下這背後的原因。

十進制和二進制表示的小數特點:

  • 在base10 (十進制)的系統(由人類使用)中,如果使用以10爲底的質因數,則可以精確表示分數。
    • 2和5是10的質因數。
    • 1/2、1/4、1/5 (0.2)、1/8 和 1/10 (0.1) 可以精確表示,因爲分母使用 10 的質因數。
    • 而 1/3、1/6 和 1/7 是重複小數,因爲分母使用 3 或 7 的質因數。
  • 另一方面,在base 2 (二進制)的系統中(由計算機使用),如果使用以 2爲底的素因子,則可以精確表示分數。
    • 2 是 2 的唯一質因數。
    • 所以 1/2、1/4、1/8 都可以精確表示,因爲分母使用 2 的質因數。
    • 而 1/5 (0.2) 或 1/10 (0.1) 是重複小數。

我們在計算數學問題的時候,使用的是十進制,計算0.1 + 0.2的結果等於0.3,沒有任何問題。但在計算機中,存儲數據使用的是二進制,數據由0和1組成。所以在對數據進行計算時,需要將數據全部轉換成二進制,再進行數據計算。

進制轉換:

十進制轉二進制的主要原則如下所示:

  • 十進制整數轉換爲二進制整數:除2取餘,逆序排列
  • 十進制小數轉換爲二進制小數:乘2取整,順序排列

這裏主要介紹小數轉換。十進制小數轉二進制,小數部分,乘 2 取整數,若乘之後的小數部分不爲 0,繼續乘以 2 直到小數部分爲 0 ,將取出的整數正向排序。

例如: 0.1 轉二進制

0.1 * 2 = 0.2 --------------- 取整數 0,小數 0.2
0.2 * 2 = 0.4 --------------- 取整數 0,小數 0.4
0.4 * 2 = 0.8 --------------- 取整數 0,小數 0.8
0.8 * 2 = 1.6 --------------- 取整數 1,小數 0.6
0.6 * 2 = 1.2 --------------- 取整數 1,小數 0.2
0.2 * 2 = 0.4 --------------- 取整數 0,小數 0.4
0.4 * 2 = 0.8 --------------- 取整數 0,小數 0.8
0.8 * 2 = 1.6 --------------- 取整數 1,小數 0.6
0.6 * 2 = 1.2 --------------- 取整數 1,小數 0.2
...

最終 0.1 的二進制表示爲 0.000110011...... 後面將會 0011 無限循環,因此二進制無法精確的保存類似 0.1 這樣的小數。那這樣無限循環也不是辦法,又該保存多少位呢?也就有了我們接下來要重點講解的 IEEE 754 標準。

IEEE 754

維基百科的鏈接,感興趣的自行了解一下。

這裏用一句話概述,IEEE754是一種二進制浮點數算術標準

IEEE 754 常用的兩種浮點數值的表示方式爲:單精確度(32位)、雙精確度(64位)。例如, java語言中的 float 通常是指 IEEE 單精確度,而 double 是指雙精確度。

在 JavaScript 中不論小數還是整數只有一種數據類型表示,這就是 Number 類型,其遵循 IEEE 754 標準,使用雙精度浮點數(double)64 位(8 字節)來存儲一個浮點數。

雙精度(64bits)浮點數的三個域:

  • sign bit(S,符號):用來表示正負號,0 爲 正 1 爲 負(1 bit)
  • exponent(E,指數):用來表示次方數(11 bits)
  • Significand(M,尾數):用來表示精確度 1 <= M < 2(52 bits)

下面看一下0.1在IEEE 754 標準中是如何存儲的?

如下圖所示(此網站):

可以看出: 指數位決定了大小範圍,小數位決定了計算精度

有兩個點需要注意:

  • IEEE 754標準規定,在保存小數Significand時,第一位默認是1,因此可以被捨去,只存儲後邊的部分。例如,1.01001保存的時候,只保存01001,等到用的時候再把1加上去。這樣,就可以節省一個位的有效數字。
  • 指數E在存儲的時候也有些特殊。爲64位浮點數時,指數佔11位,範圍爲0-2047 。但是,指數是有正有負的,因此實際值需要在此基礎上減去一箇中間數。對於64位,中間數爲1023 。

故0.1最後保存在計算機裏,成爲了以下形式:

符號位: 0
指數位: -4+1023 = 1019,二進制表示爲:01111111011
小數位:1.1001100110011001100110011001100110011001100110011001 ,捨棄第一位的1,根據最右邊未顯示的一位0舍1入,表示爲:1001100110011001100110011001100110011001100110011010

所以最終是:

    0  01111111011  1001100110011001100110011001100110011001100110011010
S符號      E指數                            M尾數

可以通過這個網站驗證,如下所示:

0.1 + 0.2 等於多少?

上面得到了0.1的二進制表示形式,下面推算一下0.2的二進制表示形式

十進制0.2轉爲二進制爲0.001100110011(0011循環),即 1.100110011(0011)*2^-3 ,存儲時:

符號位: 0

指數位:-3,實際存儲爲 -3 + 1023 = 1020 的二進制 01111111100

小數位: 1.100110011(0011循環),捨棄首位,截掉多餘位後(精度損失的原因之一)爲1001100110011001100110011001100110011001100110011010

0  01111111100  1001100110011001100110011001100110011001100110011010
S     E指數          M尾數

對階運算

接下來,計算 0.1 + 0.2 。

浮點數進行計算時,需要對階。即把兩個數的指數階碼設置爲一樣的值,然後再計算小數部分。其實對階很好理解,就和我們十進制科學記數法加法一個道理,先把指數部分化成一樣,再計算小數。

另外,需要注意一下,對階時需要小階對大階。因爲,這樣相當於,小階指數乘以倍數,小數部分相對應的除以倍數,在二進制中即右移倍數位。這樣,不會影響到小數的高位,只會移出低位,損失相對較少的精度。

因此,0.1的指數階碼爲 -4 , 需要對階爲 0.2的指數階碼 -3 。尾數部分整體右移一位。

1.100110011(0011)*2^-4 變成 0.1100110011(0011)*2^-3

符號位: 0

指數位:-3,實際存儲爲 -3 + 1023 = 1020 的二進制 1111111100

小數位: 0.1100110011(0011循環),截掉多餘位(0舍1入)後爲1100110011001100110011001100110011001100110011001101

原來的0.1
0  01111111011  1001100110011001100110011001100110011001100110011010
對階後的0.1
0  01111111100  1100110011001100110011001100110011001100110011001101

然後進行尾數部分相加 ,做加法時我們帶上整數位進行計算:

  0 01111111100   0.1100110011001100110011001100110011001100110011001101
+ 0 01111111100   1.1001100110011001100110011001100110011001100110011010
= 0 01111111100  10.0110011001100110011001100110011001100110011001100111

可以看到,產生了進位。因此,階碼需要 +1,即爲 -2,尾數部分進行低位0舍1入處理(精度損失的原因之二)。因尾數最低位爲1,需要進位。所以存儲爲:

0  1111111101  0011001100110011001100110011001100110011001100110100

最後把二進制轉換爲十進制,計算結果的二進制表示爲:

1.0011001100110011001100110011001100110011001100110100 * 2^-2

轉爲十進制,最終結果爲:

0.30000000000000004

所以 0.1 + 0.2 !== 0.3 這個問題就這樣產生了。

所以:

精度損失可能出現在<u>進制轉化</u>和<u>對階運算</u>過程中

只要在這兩步中產生了精度損失,計算結果就會出現偏差。

只有 JavaScript 中存在嗎?

這顯然不是的,這在大多數語言中基本上都會存在此問題(大都是基於 IEEE754 標準),讓我們看下 0.1 + 0.2 在一些常用語言中的運算結果。

Python

Python2 的 print 語句會將 0.30000000000000004 轉換爲字符串並將其縮短爲 “0.3”,可以使用 print(repr(.1 + .2)) 獲取所需要的浮點數運算結果。這一問題在 Python3 中已修復。

# Python2
print(.1 + .2) # 0.3
print(repr(.1 + .2)) # 0.30000000000000004

# Python3
print(.1 + .2) # 0.30000000000000004

Java

Java 中使用了 BigDecimal 類內置了對任意精度數字的支持。

System.out.println(.1 + .2); // 0.30000000000000004 (java中小數默認是double類型)

System.out.println(.1F + .2F); // 0.3 (進制轉換和對階運算後正好是0.3)

這個網站中可以看到有很多語言存在這種問題。

解決方法:

1、轉換爲整數計算

function add(num1, num2) {
    //num1 小數位的長度
 const num1Digits = (num1.toString().split('.')[1] || '').length;
 //num2 小數位的長度
 const num2Digits = (num2.toString().split('.')[1] || '').length;
 // 取最大的小數位作爲10的指數
 const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
 // 把小數都轉爲整數然後再計算
 return (num1 * baseNum + num2 * baseNum) / baseNum;
}

把計算數字 提升 10 的N次方 倍 再 除以 10的N次方。N>1。

2、使用ES6提供的極小數Number.EPSILON:

function numbersequal(a,b){ 
        return Math.abs(a-b) < Number.EPSILON;
} 
var a=0.1+0.2, b=0.3;
console.log(numbersequal(a,b)); //true

3、類庫:

(1)math.js

math.js是JavaScript和Node.js的一個廣泛的數學庫。支持數字,大數,複數,分數,單位和矩陣等數據類型的運算。

官網:http://mathjs.org/

GitHub:https://github.com/josdejong/mathjs

0.1+0.2 ===0.3實現代碼:
var math = require('mathjs')
console.log(math.add(0.1,0.2))//0.30000000000000004
console.log(math.format((math.add(math.bignumber(0.1),math.bignumber(0.2)))))//'0.3'

(2)decimal.js

爲 JavaScript 提供十進制類型的任意精度數值。

官網:http://mikemcl.github.io/decimal.js/

GitHub:https://github.com/MikeMcl/decimal.js

var Decimal = require('decimal.js')
x = new  Decimal(0.1)
y = 0.2
console.log(x.plus(y).toString())//'0.3'

(3)bignumber.js

用於任意精度算術的JavaScript庫。

官網:http://mikemcl.github.io/bignumber.js/

Github:https://github.com/MikeMcl/bignumber.js

var BigNumber = require("bignumber.js")
x = new BigNumber(0.1)
y = 0.2
console.log(x.plus(y).toString())//'0.3'

(4)big.js

用於任意精度十進制算術的小型快速JavaScript庫。

官網:http://mikemcl.github.io/big.js/

Github:https://github.com/MikeMcl/big.js/

var Big = require("big.js")
x = new Big(0.1)
y = 0.2
console.log(x.plus(y).toString())//'0.3'

總結:

發現在js中存在0.1+0.2!= 0.3這個現象後,通過上面的分析發現此現象的原因爲:在<u>進制轉化</u>和<u>對階運算</u>過程中會出現精度損失。在其他基於 IEEE754 標準的語言中也存在這種問題。並列出了幾個解決的方法。

參考鏈接:

https://github.com/qufei1993/Nodejs-Roadmap/blob/master/docs/javascript/floating-point-number-0.1-0.2.md

https://www.jianshu.com/p/e22d1268cb96

https://juejin.cn/post/6844903680362151950

https://xiaolincoding.com/os/1_hardware/float.html#_0-1-0-2-0-3

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章