js小數的數學運算和四捨五入精度問題

前言

在開發中,要進行計算,你可能會遇到小數運算,運氣好的話,你的測試測不到精度問題,但其實這是很嚴重的,如下兩個典型例子先感覺如下java

0.1 + 0.2 = 0.30000000000000004

35.41 * 100 = 3540.9999999999995
複製代碼

是否是出乎你的意料?安全

寫這篇文章的緣由是網上找了些資料,要不就是介紹不全的,要不就是存在錯誤的(可能你們沒發現),要不就是方案還有待增強的。因而我決定本身整理出一份較爲全面而不誤導別人的文章出來(文章方案對網上大部分資料存在的缺陷進行彌補加強),若是您發現不足,請告訴我,虛心請教。bash

如下咱們瞭解緣由以及尋找解決方案。ide

緣由

js的數字存儲狀況

在計算機中存儲的信息都是二進制來表示,咱們都知道,js中數字類型只有Number,不像其餘語言如javaintdouble類型等。它的實現遵循 IEEE 754 標準,使用64位固定長度來表示,也就是標準的 double 雙精度浮點數。函數

其中這64位數又分爲三部分(從左往右看):測試

  • 符號位:第一位爲符號位,0表示正數,1表示負數;
  • 指數位:中間的11位存儲指數,用來表示次方數
  • 尾數位:最後的52位就是尾數,超出部分會0舍1入(相似四捨五入)

計算過程

以例子0.1 + 0.3展開說明:ui

  1. 先把0.1和0.3轉化位二進制,會發現轉化後的二進制會陷入循環,超出了上面說的尾數52位,由於小數位只能到52位,因此進行0舍1入的處理,0.1 -> 0.0001100110011001100110011001100110011001100110011010; 0.2 -> 0.0011001100110011001100110011001100110011001100110011
  2. 轉化後的二進制進行加法運算,得0.0100110011001100110011001100110011001100110011001101,轉化爲十進制就是0.30000000000000004

因而就這樣,獲得了一個出乎你預料的結果了。並非全部小數計算都會這樣,是一些小數轉化位二進制時出現超出52位數時,就可能會出現這種意外結果。spa

解決方案

上面咱們知道了出現意外結果是由於小數轉化爲二進制時發生問題,那整數呢?很天然,整數也是有最大值安全值的,就是2的53次方,爲9007199254740992。超出這個數值的計算一樣也是帶來精度問題。可是咱們通常使用是不會超出這個值的(若是你的需求也要考慮超出這個數值,抱歉,我無能爲力)code

若是你的需求能夠無視上面整數最大安全值的弊端,那麼接下來的解決方案纔是適合你的。ip

解放方案:把小數運算中的小數,升級轉化爲整數(乘以10的n次冪),在進行運算,將最後結果再降級(除以10的n次冪)

上面的描述僅僅是一個思路,一個轉化思路。怎麼將小數轉化爲整數,這就講究了,不能真的進行乘法計算,乘以10的n次冪,還記得前言裏的第二個例子嗎,這種轉化整數的方式自己就是一個小數運算,因此仍是會出現問題。

咱們要用字符串替換的方式來實現這個「升級」:

原值:1.23

轉化過程:
1. 化爲字符串 '1.23'
2. '1.23'.replace('.', ''),得'123',至關於乘以10的2次冪
複製代碼

結合實際例子來了解大概的一個狀況,例子1.1 + 1.22

1. 分別對1.1和1.22進行字符串替換,變成'110''122',至關於乘以10的2次冪
2. 對替換結果轉化回數字類型而後再進行加法運算:110 + 122 = 232
3. 對232除以10的2次冪,得2.32
複製代碼

以上就是一個轉化和運算過程。

基於上述基本思想,我對加減乘除進行一個方法封裝,方便你們進行小數運算。

/** * 帶有小數的加法/減法運算 * 減法實際上可當作加法,因此若是要作減法,只需第二個參數即被減數傳負值便可 * @param {Number} arg1 - 加數/減數 * @param {Number} arg2 - 加數/被減數 */
function addFloat(arg1, arg2) {
    let m = 0; // 記錄兩個加數中最長的小數位長度
    let arg1Str = arg1 + '';
    let arg2Str = arg2 + '';
    const arg1StrFloat = arg1Str.split('.')[1];
    const arg2StrFloat = arg2Str.split('.')[1];
    arg1StrFloat && (m = arg1StrFloat.length);
    arg2StrFloat && (m = m > arg2StrFloat.length ? m : arg2StrFloat.length);
    arg1Str = arg1.toFixed(m); // 主要是爲了補零
    arg2Str = arg2.toFixed(m);
    const transferResult = +(arg1Str.replace('.', '')) + +(arg2Str.replace('.', ''));
    return transferResult / Math.pow(10, m);
};

/** * 帶有小數的乘法運算 * @param {Number} arg1 - 因數 * @param {Number} arg2 - 因數 */
function multiplyFloat(arg1, arg2) {
    let m = 0;
    const arg1Str = arg1 + '';
    const arg2Str = arg2 + '';
    const arg1StrFloat = arg1Str.split('.')[1];
    const arg2StrFloat = arg2Str.split('.')[1];
    arg1StrFloat && (m += arg1StrFloat.length);
    arg2StrFloat && (m += arg2StrFloat.length);
    const transferResult = +(arg1Str.replace('.', '')) * +(arg2Str.replace('.', ''));
    return transferResult / Math.pow(10, m);;
};

/** * 有小數的除法運算 * @param {Number} arg1 - 除數 * @param {Number} arg2 - 被除數 */
function divideFloat(arg1, arg2) {
    const arg1Str = arg1 + '';
    const arg2Str = arg2 + '';
    const arg1StrFloat = arg1Str.split('.')[1] || '';
    const arg2StrFloat = arg2Str.split('.')[1] || '';
    const m = arg2StrFloat.length - arg1StrFloat.length;
    const transferResult = +(arg1Str.replace('.', '')) / +(arg2Str.replace('.', ''));
    return transferResult * Math.pow(10, m);;
};
複製代碼

小缺陷

任何一個方案都不能十全十美的,多多少少會有一些限制,畢竟需求是多種多樣的。我寫文章的習慣就是得告知別人缺陷,而不能忽悠別人。知道本身寫的東西的利,也得知道本身的弊。

該方案會有幾個小缺陷:

  • 進行運算的值不能超過js的數字最大安全值9007199254740992
  • 加法中運用到了toFixed方法,該方法的參數num有個限制:當 num 過小或太大時拋出異常 RangeError。0 ~ 20 之間的值不會引起該異常。

以上着兩個小缺陷其實在咱們正常開發中,通常不會觸及到,由於這樣的數字和小數位實在太長了,咱們通常需求不會要求進行這麼大的運算以及小數點保留位。

四捨五入(保留小數位)

這裏順着這個主題,能夠順帶講一下在js中進行四捨五入或進行小數位保留的狀況。

不少人會想到,用tofixed進行四捨五入,實際上,tofixed函數對於四捨五入的規則與數學中的規則不一樣,使用的是銀行家舍入規則:實際上是一種四捨六入五取偶(又稱四捨六入五留雙)法。表現爲:

四捨六入五考慮,五後非零就進一,五後爲零看奇偶,五前爲偶應捨去,五前爲奇要進一。

很顯然,這並非咱們想要的結果。可是Math.round方法,就是咱們所熟知的四捨五入規則,咱們能夠利用該方法擴展到小數位的四捨五入。

網上不少資料都有介紹這種方式:

對小數乘以10的n次冪,再用Math.round取整,再除以10的n次冪,就能獲得進過四捨五入後的指定小數位了。

通過上文個人介紹,只要對小數進行數學運算,都有可能出現精度不許確的問題。因此最終的一步正如網上這麼多資料說的那樣作法,可是其中的乘法和除法,請用文中封裝好的方法來進行,而不是直接進行小數的數學運算。

這裏我封裝一個四捨五入的方法

function roundFloat (value, decimal = 2) {
    const n = Math.pow(10, decimal);
    return divideFloat(Math.round(multiplyFloat(value, n)), n).toFixed(decimal);
}
複製代碼

可能有人會疑問,我這裏爲何還要用toFixed,不是說這個不許的嗎?

其實我這裏並無用toFixed作實際性上的四捨五入,真正作了四捨五入的工做在調用toFixed前就已經完成了,最後還用toFixed只是作潤色做用,例如1.1你要保留小數點後兩位的話,理應顯示成1.10,可是js中數字類型顯示出來的話,是不會有後面的不起做用的0,所以若是你想顯示出指定位數,不足補零的話,就得用toFixed轉化位字符串了。

所以上面的方法的返回結果是一個字符串類型,這你們要注意了,若是你沒有這方面的需求,可自行拿掉後面的toFixed調用。


未經容許,請勿私自轉載

相關文章
相關標籤/搜索