不知道你們在使用JS的過程當中有沒有發現某些浮點數運算的時候,獲得的結果存在精度問題:好比0.1 + 0.2 = 0.30000000000000004以及7 * 0.8 = 5.6000000000000005等等。
到底是什麼緣由形成了這個問題?其實是由於計算機內部的信息都是由二進制方式表示的,即0和1組成的各類編碼,但因爲某些浮點數沒辦法用二進制準確的表示出來,也就帶來了一系列精度問題。固然這也不是JS獨有的問題。
接下來讓咱們以0.1+0.2爲例,深刻理解一下浮點數的運算方法,以及使用JS時應該如何規避這個問題。這個問題很基礎,但也頗有了解的必要,你們就當是複習一下《計算機組成原理》吧。
html
經過後面的幾個小章節,將會大體爲你們介紹如下幾個方面內容:node
● 浮點數的二進制表示方式
● IEEE 754 標準是什麼
● 避開浮點數計算精度問題的方案
● 測試框架(Mocha)的基本用法git
1、計算機的運算方式github
㈠ 如何將小數轉成二進制npm
① 整數部分:除2取餘數,若商不爲0則繼續對它除2,當商爲0時則將全部餘數逆序排列;
② 小數部分:乘2取整數部分,若小數不爲0則繼續乘2,直至小數部分爲0將取出的整數位正序排列。(若小數部分沒法爲零,根據有效位數要求取得相應數值,位數後一位0舍1入進行取捨)
利用上述方法,咱們嘗試一下將0.1轉成二進制:
0.1 * 2 = 0.2 - - - - - - - - - - 取0
0.2 * 2 = 0.4 - - - - - - - - - - 取0
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,因此二進制是沒辦法精確表示0.1的。
那麼0.1的二進制表示是:0.000110011......0011...... (0011無限循環)
而0.2的二進制表示則是:0.00110011......0011...... (0011無限循環)
而具體應該保存多少位數,則須要根據使用的是什麼標準來肯定,也就是下一節所要講到的內容。
json
㈡ IEEE 754 標準瀏覽器
IEEE 754 標準是IEEE二進位浮點數算術標準(IEEE Standard for Floating-Point Arithmetic)的標準編號。IEEE 754 標準規定了計算機程序設計環境中的二進制和十進制的浮點數自述的交換、算術格式以及方法。
根據IEEE 754標準,任意一個二進制浮點數均可以表示成如下形式:
S爲數符,它表示浮點數的正負(0正1負);M爲有效位(尾數);E爲階碼,用移碼錶示,階碼的真值都被加上一個常數(偏移量)。框架
尾數部分M一般都是規格化表示的,即非"0"的尾數其第一位老是"1",而這一位也稱隱藏位,由於存儲時候這一位是會被省略的。好比保存1.0011時,只保存0011,等讀取的時候才把第一位的1加上去,這樣作至關於多保存了1位有效數字。函數
經常使用的浮點格式有:
① 單精度:
這是32位的浮點數,最高的1位是符號位S,後面的8位是指數E,剩下的23位爲尾數(有效數字)M;
其真值爲:
② 雙精度:
這是64位的浮點數,最高的1位是符號位S,後面的11位是指數E,剩下的52位爲尾數(有效數字)M;
其真值爲:
JavaScript只有一種數字類型number,而number使用的就是IEEE 754雙精度浮點格式。依據上述規則,接下來咱們就來看看JS是如何存儲0.1和0.2的:
0.1是正數,因此符號位是0;
而其二進制位是0.000110011......0011...... (0011無限循環),進行規格化後爲1.10011001......1001(1)*2^-4,根據0舍1入的規則,最後的值爲
2^-4 * 1.1001100110011001100110011001100110011001100110011010
而指數E = -4 + 1023 = 1019
由此可得,JS中0.1的二進制存儲格式爲(符號位用逗號分隔,指數位用分號分隔):
0,01111111011;1001100110011001100110011001100110011001100110011010
0.2則爲:
0,01111111100;1001100110011001100110011001100110011001100110011010
Q1:指數位E(階碼)爲什麼用移碼錶示?
A1:爲了便於判斷其大小。
單元測試
㈢ 浮點數運算
0.1 => 0,01111111011;1001100110011001100110011001100110011001100110011010
0.2 => 0,01111111100;1001100110011001100110011001100110011001100110011010
浮點數的加減運算按如下幾步進行:
① 對階,使兩數的小數點位置對齊(也就是使兩數的階碼相等)。
因此要先求階差,階小的尾數要根據階差來右移(尾數位移時可能會發生數丟失的狀況,影響精度)
由於0.1和0.2的階碼和尾數均爲正數,因此它們的原碼、反碼及補碼都是同樣的。(使用補碼進行運算,計算過程當中使用雙符號)
△階差(補碼) = 00,01111111011 - 00,01111111100 = 00,01111111011 + 11,10000000100 = 11,11111111111
由上可知△階差爲-1,也就是0.1的階碼比0.2的小,因此要把0.1的尾數右移1位,階碼加1(使0.1的階碼和0.2的一致)
最後0.1 => 0,01111111100;1100110011001100110011001100110011001100110011001101(0)
注:要注意0舍1入的原則。之因此右移一位,尾數補的是1,是由於隱藏位的數值爲1(默認是不存儲的,只有讀取的時候才加上)
② 尾數求和
0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
——————————————————————————————
10.0110011001100110011001100110011001100110011001100111
③ 規格化
針對步驟②的結果,須要右規(即尾數右移1位,階碼加1)
sum = 0.1 + 0.2 = 0,01111111101;1.0011001100110011001100110011001100110011001100110011(1)
注:右規操做,可能會致使低位丟失,引發偏差,形成精度問題。因此就須要步驟④的舍入操做
④ 舍入(0舍1入)
sum = 0,01111111101;1.0011001100110011001100110011001100110011001100110100
⑤ 溢出判斷
根據階碼判斷浮點運算是否溢出。而咱們的階碼01111111101即不上溢,也不下溢。
至此,0.1+0.2的運算就已經結束了。接下來,咱們一塊兒來看看上面計算獲得的結果,它的十進制數是多少。
<1> 先將它非規格化,獲得二進制形式:
sum = 0.010011001100110011001100110011001100110011001100110100
<2> 再將其轉成十進制
sum = 2^2 + 2^5 + 2^6 + ... + 2^52 = 0.30000000000000004440892098500626
如今你應該明白JS中 0.30000000000000004 這個結果怎麼來的吧。
Q2:計算機運算爲什麼要使用補碼?
A2:能夠簡化計算機的運算步驟,且只用設加法器,如作減法時若能找到與負數等價的正數來代替該負數,就能夠把減法操做用加法代替。而採用補碼,就能達到這個效果。
2、浮點數精度問題的解決方法
㈠ 簡單解決方案
個人思路就是將小數轉成整數來運算,以後再轉回小數。代碼也比較簡單,就直接貼出來了。
'use strict' var accAdd = function(num1, num2) { num1 = Number(num1); num2 = Number(num2); var dec1, dec2, times; try { dec1 = countDecimals(num1)+1; } catch (e) { dec1 = 0; } try { dec2 = countDecimals(num2)+1; } catch (e) { dec2 = 0; } times = Math.pow(10, Math.max(dec1, dec2)); // var result = (num1 * times + num2 * times) / times; var result = (accMul(num1, times) + accMul(num2, times)) / times; return getCorrectResult("add", num1, num2, result); // return result; }; var accSub = function(num1, num2) { num1 = Number(num1); num2 = Number(num2); var dec1, dec2, times; try { dec1 = countDecimals(num1)+1; } catch (e) { dec1 = 0; } try { dec2 = countDecimals(num2)+1; } catch (e) { dec2 = 0; } times = Math.pow(10, Math.max(dec1, dec2)); // var result = Number(((num1 * times - num2 * times) / times); var result = Number((accMul(num1, times) - accMul(num2, times)) / times); return getCorrectResult("sub", num1, num2, result); // return result; }; var accDiv = function(num1, num2) { num1 = Number(num1); num2 = Number(num2); var t1 = 0, t2 = 0, dec1, dec2; try { t1 = countDecimals(num1); } catch (e) { } try { t2 = countDecimals(num2); } catch (e) { } dec1 = convertToInt(num1); dec2 = convertToInt(num2); var result = accMul((dec1 / dec2), Math.pow(10, t2 - t1)); return getCorrectResult("div", num1, num2, result); // return result; }; var accMul = function(num1, num2) { num1 = Number(num1); num2 = Number(num2); var times = 0, s1 = num1.toString(), s2 = num2.toString(); try { times += countDecimals(s1); } catch (e) { } try { times += countDecimals(s2); } catch (e) { } var result = convertToInt(s1) * convertToInt(s2) / Math.pow(10, times); return getCorrectResult("mul", num1, num2, result); // return result; }; var countDecimals = function(num) { var len = 0; try { num = Number(num); var str = num.toString().toUpperCase(); if (str.split('E').length === 2) { // scientific notation var isDecimal = false; if (str.split('.').length === 2) { str = str.split('.')[1]; if (parseInt(str.split('E')[0]) !== 0) { isDecimal = true; } } let x = str.split('E'); if (isDecimal) { len = x[0].length; } len -= parseInt(x[1]); } else if (str.split('.').length === 2) { // decimal if (parseInt(str.split('.')[1]) !== 0) { len = str.split('.')[1].length; } } } catch(e) { throw e; } finally { if (isNaN(len) || len < 0) { len = 0; } return len; } }; var convertToInt = function(num) { num = Number(num); var newNum = num; var times = countDecimals(num); var temp_num = num.toString().toUpperCase(); if (temp_num.split('E').length === 2) { newNum = Math.round(num * Math.pow(10, times)); } else { newNum = Number(temp_num.replace(".", "")); } return newNum; }; var getCorrectResult = function(type, num1, num2, result) { var temp_result = 0; switch (type) { case "add": temp_result = num1 + num2; break; case "sub": temp_result = num1 - num2; break; case "div": temp_result = num1 / num2; break; case "mul": temp_result = num1 * num2; break; } if (Math.abs(result - temp_result) > 1) { return temp_result; } return result; };
基本用法:
加法: accAdd(0.1, 0.2) // 獲得結果:0.3 減法: accSub(1, 0.9) // 獲得結果:0.1 除法: accDiv(2.2, 100) // 獲得結果:0.022 乘法: accMul(7, 0.8) // 獲得結果:5.6 countDecimals()方法:計算小數位的長度 convertToInt()方法:將小數轉成整數 getCorrectResult()方法:確認咱們的計算結果無誤,以防萬一
㈡ 使用特殊進制類型類庫
若是你須要很是精確的結果,能夠考慮使用特殊的進制數據類型,以下面的這個叫bignumber的類庫:
https://github.com/MikeMcl/bignumber.js
3、經過單元測試驗證解決方案的可行性
測試能夠保證咱們的代碼質量。而咱們的那個精確計算的方案須要大量的測試來驗證其正確性,經過編寫測試代碼進行測試省時省力,並且方便之後修改代碼,能很快確認代碼是否有誤。
測試使用的是如今較流行的JavaScript測試框架Mocha,在瀏覽器和Node環境均可以使用。
經過如下幾個簡單的步驟安裝、使用它:
① 安裝好Node.js環境
② 到工程目錄下,經過npm init命令初始化生成package.json
③ 在工程目錄下安裝相關的類庫(測試框架mocha,斷言庫chai)
npm install mocha chai --save
④ 測試代碼的編寫
由於不涉及到界面,因此咱們就以node的方式來測試代碼
④-1 先將要測試的函數export出來,便於測試代碼調用。大體以下:
if (typeof module !== 'undefined' && module.exports) { var calc = {}; calc.countDecimals = countDecimals; calc.convertToInt = convertToInt; calc.getCorrectResult = getCorrectResult; calc.accAdd = accAdd; calc.accSub = accSub; calc.accDiv = accDiv; calc.accMul = accMul; module.exports = calc; }
④-2 編寫測試代碼(以測試countDecimals方法爲例)
var chai = require('chai'); var assert = chai.assert; // Using Assert style var expect = chai.expect; // Using Expect style var should = chai.should(); // Using Should style var calc = require('./accurateCalculate'); var countDecimals = calc.countDecimals; function test_countDecimals(info, num, expected) { it(info, function(done) { expect(countDecimals(num)).to.be.equal(expected); done(); }); } describe('TEST countDecimals', function() { describe('TEST Number', function() { test_countDecimals('3', 3, 0); test_countDecimals('3.00', 3.00, 0); test_countDecimals('3.01', 3.01, 2); test_countDecimals('3e0', 3e0, 0); test_countDecimals('3e10', 3e10, 0); test_countDecimals('3e-10', 3e-10, 10); test_countDecimals('3.01e0', 3.01e0, 2); test_countDecimals('3.01e10', 3.01e10, 0); test_countDecimals('3.01e-10', 3.01e-10, 12); }); describe('TEST String', function() { test_countDecimals('3', '3', 0); test_countDecimals('3.00', '3.00', 0); test_countDecimals('3.01', '3.01', 2); test_countDecimals('3.00e0', '3.00e0', 0); test_countDecimals('3.00e10', '3.00e10', 0); test_countDecimals('3.00e-10', '3.00e-10', 10); test_countDecimals('30e-1', '30e-1', 0); test_countDecimals('30e-2', '30e-2', 1); }); });
首先須要require語句,導入相關類庫以及剛纔待測js中export出來的對象。
describe塊稱爲"測試套件"(test suite),它是一個函數,可在經過它的第一個參數描述這塊測試的是什麼內容。
it塊稱爲"測試用例"(test case),表示一個單獨的測試,是測試的最小單位。第一個參數是測試用例的名稱。
測試腳本里面應該包括一個或多個describe塊,每一個describe塊應該包括一個或多個it塊。
上面的測試代碼使用的是expect斷言風格:
expect(調用待測函數()).to.be.equal(期待的返回值);
所謂"斷言",就是判斷被測函數的實際執行結果與預期結果是否一致。
⑤ 編寫package.json中的test項
"scripts": { "test": "mocha test.js" },
⑥ 運行測試代碼
配置好package.json後,只需運行npm test命令便可看到測試結果:
更多Mocha的詳盡用法能夠參閱這篇博客:
http://www.ruanyifeng.com/blog/2015/12/a-mocha-tutorial-of-examples.html
4、結論
JS浮點數計算精度問題是由於某些小數無法用二進制精確表示出來。JS使用的是IEEE 754雙精度浮點規則。
而規避浮點數計算精度問題,可經過如下幾種方法:
● 調用round() 方法四捨五入或者toFixed() 方法保留指定的位數(對精度要求不高,可用這種方法)● 將小數轉爲整數再作計算,即前文提到的那個簡單的解決方案● 使用特殊的進制數據類型,如前文提到的bignumber(對精度要求很高,可藉助這些相關的類庫)