談 JavaScript 浮點數計算精度問題(如0.1+0.2!==0.3)

不知道你們在使用JS的過程當中有沒有發現某些浮點數運算的時候,獲得的結果存在精度問題:好比0.1 + 0.2 = 0.30000000000000004以及7 * 0.8 = 5.6000000000000005等等。
bash

到底是什麼緣由形成了這個問題?其實是由於計算機內部的信息都是由二進制方式表示的,即0和1組成的各類編碼,但因爲某些浮點數沒辦法用二進制準確的表示出來,也就帶來了一系列精度問題。固然這也不是JS獨有的問題
框架

接下來讓咱們以 0.1+0.2 爲例,深刻理解一下浮點數的運算方法,以及使用JS時應該如何規避這個問題。這個問題很基礎,但也頗有了解的必要,你們就當是複習一下《計算機組成原理》吧。
測試

經過後面的幾個小章節,將會大體爲你們介紹如下幾個方面內容:ui

● 浮點數的二進制表示方式
● IEEE 754 標準是什麼
● 避開浮點數計算精度問題的方案
● 測試框架(Mocha)的基本用法編碼

1. 計算機的運算方式

(1)如何將小數轉化成二進制

① 整數部分:除2取餘數,若商不爲0則繼續對它除2,當商爲0時則將全部餘數逆序排列; spa

② 小數部分:乘2取整數部分,若小數不爲0則繼續乘2,直至小數部分爲0將取出的整數位正序排列。(若小數部分沒法爲零,根據有效位數要求取得相應數值,位數後一位0舍1入進行取捨) 設計

利用上述方法,咱們嘗試一下將0.1轉成二進制:3d

 0.1 * 2 = 0.2 - - - - - - - - - - 取0 code

0.2 * 2 = 0.4 - - - - - - - - - - 取0 cdn

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無限循環) 而具體應該保存多少位數,則須要根據使用的是什麼標準來肯定,也就是下一節所要講到的內容。

(2)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:爲了便於判斷其大小。

(3)浮點運算

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. 浮點精度問題的解決辦法

(1)簡單解決方案

個人思路就是將小數轉成整數來運算,以後再轉回小數。代碼也比較簡單,就直接貼出來了。

'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()方法:確認咱們的計算結果無誤,以防萬一 


3. 總結:

JS浮點數計算精度問題是由於某些小數無法用二進制精確表示出來。JS使用的是IEEE 754雙精度浮點規則。 而規避浮點數計算精度問題,可經過如下幾種方法: 

● 調用round() 方法四捨五入或者toFixed() 方法保留指定的位數(對精度要求不高,可用這種方法) 

● 將小數轉爲整數再作計算,即前文提到的那個簡單的解決方案 

● 使用特殊的進制數據類型,如前文提到的bignumber(對精度要求很高,可藉助這些相關的類庫)

相關文章
相關標籤/搜索