JS數值存儲運算原理
前言
相信大家都看過這些曾經在社區比較火的文章:
- 0.1 + 0.2 與0.3為什么不相等?
- 為什么 3.0000000000000002 === 3表達式為true ?
- 等...
造成這些問題的背后原因都是由于javaScript采用了 IEEE754 標準,全稱 IEEE 二進制浮點數算術標準。所以說這個問題其實不止是會在javaScript中出現,而是「其他遵循 [IEEE 754]標準的語言也會出現這個問題」
并且自己在最近的工作中也遇到了這個問題,由于javaScript精度丟失而造成詭異問題!
javaScript車禍現場
上面三個例子在我們在控制臺里面驗證一遍,是不是瞬間覺得奇怪的知識又增加了?
javaScript這令人窒息的操作是不是讓很多后端人員口吐芬芳了,甚至是很多前端人員都覺得明明都是送分題,卻成了JS的送命題,工作中許多不經意間寫出的bug,往往是由于JS的不按常理出牌。
說了這么多,我們也改變不了這一現狀,那就嘗試去理解它吧~
計算機運算
學過計算機相關同學都知道,我們的計算機底層元算采用的是二進制,而不是我們平常用的十進制!
二進制
「為什么計算機要采用二進制,而不是十進制?」
以下是在知乎上看到的回答,我覺得這個理解是比較到位的。
計算機本身的理論模型,和采用哪個數學上的進制完全無關,十進制也好,五進制也好,二進制也好,進制在數學上都是等價的,并沒有哪個進制擁有其他進制無法實現的計算。
但計算機的實現是個工程問題,需要和真實的物理環境打交道,我們現在是用電路去實現我們的計算機模型,那就需要和物理電路打交道,需要考慮到信號的衰減延遲,電路器件的各種電氣特性,什么電磁波干擾電流擾動,也就是會有失真的情況出現,而要最大程度避免衰減,失真對計算機這個完美世界造成破壞,同時要考慮電路的設計,制作成本,就需要最簡單化的物理實現方案。
電子計算機確實是可以做成十進制的,就像題主說的像燈泡亮度分成十種亮度那樣,但與此同時會出現很多的工程問題,比如對電子器件的精度和穩定性要求很高,電路設計的復雜性提升等等,到頭來還不如就用二進制,在成本和質量上最劃算。
事實上,不但十進制不行,十六進制、八進制、四進制也都比不上二進制。理論上已經證明效率最高的進制是e,離e最近的其實是三進制。但三進制不方便表示,不過也有人研究,前蘇聯就做過三進制計算機,國內也有,但并沒有走出實驗室。效率是一方面,實現成本又是一方面,最終大家還是覺得二進制實現起來最方便。
原碼、反碼、補碼
「為運算方便,機器數有 3 種表示法,即原碼、反碼和補碼」。
原碼
原碼是一種計算機中對數字的二進制定點表示法。「原碼表示法在數值前面增加了一位符號位」。
反碼
正數的反碼和原碼一樣,
負數的反碼就是在原碼的基礎上符號位保持不變,其他位取反。
補碼
正數和 0 的補碼就是該數字本身。
「負數的補碼則是將其對應正數按位取反再加 1」
二進制轉換
「正整數的轉換方法」:除二取余,然后倒序排列,高位補零。
例如21的轉換
商 余
21/2 10 1
10/2 5 0
5/2 2 1
2/2 1 0
1/2 0 1
21的二進制為10101,然后高位補0為00010101
「負整數的轉換方法」:將對應的正整數轉換成二進制后,對二進制取反,然后對結果再加一。
例如-21
先把21轉換成二進制 00010101
逐位取反:11101010
再加1:11101011(補碼)
「小數的轉換方法」:對小數點以后的數乘以2,取整數部分,再取小數部分乘2,以此類推……直到小數部分為0或位數足夠。取整部分按先后順序排列即可。
例如123.4:
0.4*2=0.8 ——————-> 取0
0.8*2=1.6 ——————-> 取1
0.6*2=1.2 ——————-> 取1
0.2*2=0.4 ——————-> 取0
0.4*2=0.8 ——————-> 取0
………… 后面就是循環了
按順序寫出:0.4 = 0.01100110……(0110循環)
整數部分123的二進制是 1111011
則123.4的二進制表示為:1111011.011001100110……
發現了什么?十進制小數轉二進制后大概率出現無限位數!但我們的javaScript采用了「IEEE754」 標準,全稱 「IEEE 二進制浮點數算術標準」。
由于IEEE 754尾數位數限制,會將后面多余的位截掉。
javaScript 與 IEEE 754
“JavaScript 采用 IEEE 754 標準,數值存儲為64位雙精度格式,數值精度最多可以達到 53 個二進制位(1 個隱藏位與 52 個有效位)
在這個標準下,我們會用1位存儲 S(sign),0 表示正數,1 表示負數。用11位存儲 E(exponent) + bias,對于11位來說,bias 的值是 2^(11-1) - 1,也就是 1023。用52 位存儲 Fraction。
由于javaScript采用的是IEE754標準,所以在進制之間的轉換過程中可能會導致精度丟失,這是造成javaScript運算翻車的罪魁禍首!
破案
0.1+0.2 與 0.3為什么不相等?
0.1.toString(2)
// '0.0001100110011001100110011001100110011001100110011001101' // 57
// 按 IEEE754 格式 57 - 4 = 52可以精確存儲
0.2.toString(2)
// '0.001100110011001100110011001100110011001100110011001101' // 56
// 按 IEEE754 格式 56 - 3 = 53 會丟棄最后一位數
0.3.toString(2)
// '0.010011001100110011001100110011001100110011001100110011' // 56
// 按 IEEE754 格式 56 - 2 = 54 會丟棄最后兩位數
/*總結:
存儲0.1沒有誤差, 存儲 0.2丟棄最后一位 1 存儲0.3丟棄最后2位 11,
顯然存儲0.3丟棄的數值>存儲0.2丟棄的數值
經分析 0.1 + 0.2 應該大于 0.3
*/
0.1 + 0.2 > 0.3 // true
為什么 3.0000000000000002 === 3表達式為true ?
手動將 3.0000_0000_0000_0002轉換成二進制浮點數
整數部分為 11?
小數部分0.0000_0000_0000_0002
0.0000_0000_0000_0002.toString(2)
'0.0000000000000000000000000000000000000000000000000000111001101001010110010100101111101100010001001101111'
注意小數點后面正好有52個0
0.0000_0000_0000_0002.toString(2).length // 105 105
將 3.0000000000000002 用 IEEE754 格式表示
- 符號S: 正數,0
- 指數位E:11 = 1.1 * 2^1 (二進制),E = 1023 + 1 = 1024 = 10000000000(二進制)
- 尾數位M:0.1.....0
所以該浮點數格式為: 0 1000_0000_000 1...000(一共52個0) 這個數正好是3。
如何解決精度問題?
目前有許多第三方庫可以解決javaScript精度丟失的問題
math.js
math.js是JavaScript和Node.js的一個廣泛的數學庫。支持數字,大數,復數,分數,單位和矩陣等數據類型的運算。
官網:mathjs.org/ GitHub:github.com/josdejong/m…
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'
decimal.js
為 JavaScript 提供十進制類型的任意精度數值。
官網:mikemcl.github.io/decimal.js/
GitHub:github.com/MikeMcl/dec…
var Decimal = require('decimal.js')
x = new Decimal(0.1)
y = 0.2
console.log(x.plus(y).toString())//'0.3'
bignumber.js
用于任意精度算術的JavaScript庫。
官網:mikemcl.github.io/bignumber.j…
Github:github.com/MikeMcl/big…
var BigNumber = require("bignumber.js")
x = new BigNumber(0.1)
y = 0.2
console.log(x.plus(y).toString()) //'0.3'