例子:
你有沒有發現一個場景,在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的一個廣泛的數學庫。支持數字,大數,複數,分數,單位和矩陣等數據類型的運算。
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://www.jianshu.com/p/e22d1268cb96
https://juejin.cn/post/6844903680362151950
https://xiaolincoding.com/os/1_hardware/float.html#_0-1-0-2-0-3