JavaScript 加減危機 —— 為什麼會出現這樣的結果?

jsliang發表於2019-11-26

一 目錄

不折騰的前端,和鹹魚有什麼區別

目錄
一 目錄
二 前言
三 問題復現
3.1 根源:IEEE 754 標準
3.2 復現:計算過程
3.3 擴充套件:數字安全
四 解決問題
4.1 toFixed()
4.2 手寫簡易加減乘除
五 現成框架
六 參考文獻

二 前言

返回目錄

在日常工作計算中,我們如履薄冰,但是 JavaScript 總能給我們這樣那樣的 surprise~

  1. 0.1 + 0.2 = ?
  2. 1 - 0.9 = ?

如果小夥伴給出內心的結果:

  1. 0.1 + 0.2 = 0.3
  2. 1 - 0.9 = 0.1

那麼小夥伴會被事實狠狠地扇臉:

console.log(0.1 + 0.2); // 0.30000000000000004
console.log(1 - 0.9); // 0.09999999999999998
複製程式碼

為什麼會出現這種情況呢?我們們一探究竟!

三 問題復現

返回目錄

下面,我們會通過探討 IEEE 754 標準,以及 JavaScript 加減的計算過程,來複現問題。

3.1 根源:IEEE 754 標準

返回目錄

JavaScript 裡面的數字採用 IEEE 754 標準的 64 位雙精度浮點數。該規範定義了浮點數的格式,對於 64 位的浮點數在記憶體中表示,最高的 1 位是符號為,接著的 11 位是指數,剩下的 52 位為有效數字,具體:

  • 第 0 位:符號位。用 s 表示,0 表示為正數,1 表示為負數;
  • 第 1 - 11 位:儲存指數部分。用 e 表示;
  • 第 12 - 63 位:儲存小數部分(即有效數字)。用 f 表示。

JavaScript 加減危機 —— 為什麼會出現這樣的結果?

符號位決定一個數的正負,指數部分決定數值的大小,小數部分決定數值的精度。

IEEE 754 規定,有效數字第一位預設總是 1,不儲存在 64 位浮點數之中。

也就是說,有效數字總是 1.XX......XX的形式,其中 XX......XX 的部分儲存在 64 位浮點數之中,最長可能為 52 位。

因此,JavaScript 提供的有效數字最長為 53 個二進位制位(64 位浮點的後 52 位 + 有效數字第一位的 1)。

3.2 復現:計算過程

返回目錄

通過 JavaScript 計算 0.1 + 0.2 時,會發生什麼?

1、 將 0.1 和 0.2 換成二進位制表示:

0.1 -> 0.0001100110011001...(無限)
0.2 -> 0.0011001100110011...(無限)
複製程式碼

浮點數用二進位制表示式是無窮的

2、 因為 IEEE 754 標準的 64 位雙精度浮點數的小數部分最多支援 53 位二進位制位,所以兩者相加之後得到二進位制為:

0.0100110011001100110011001100110011001100110011001100
複製程式碼

因為浮點數小數位的限制,這個二進位制數字被截斷了,用這個二進位制數轉換成十進位制,就成了 0.30000000000000004,從而在進行算數計算時產生誤差。

3.3 擴充套件:數字安全

返回目錄

在看完上面小數的計算不精確後,jsliang 覺得有必要再聊聊整數,因為整數同樣存在一些問題:

console.log(19571992547450991);
// 19571992547450990

console.log(19571992547450991 === 19571992547450994);
// true
複製程式碼

是不是很驚奇!

因為 JavaScript 中 Number 型別統一按浮點數處理,整數也不能逃避這個問題:

// 最大值
const MaxNumber = Math.pow(2, 53) - 1;
console.log(MaxNumber); // 9007199254740991
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991

// 最小值
const MinNumber = -(Math.pow(2, 53) - 1);
console.log(MinNumber); // -9007199254740991
console.log(Number.MIN_SAFE_INTEGER); // -9007199254740991
複製程式碼

即整數的安全範圍是: [-9007199254740991, 9007199254740991]

超過這個範圍的,就存在被捨去的精度問題。

當然,這個問題並不僅僅存在於 JavaScript 中,幾乎所有采用了 IEEE-745 標準的程式語言,都會有這個問題,只不過在很多其他語言中已經封裝好了方法來避免精度的問題。

而因為 JavaScript 是一門弱型別的語言,從設計思想上就沒有對浮點數有個嚴格的資料型別,所以精度誤差的問題就顯得格外突出。

到此為止,我們可以看到 JavaScript 在處理數字型別的操作時,可能會產生一些問題。

事實上,工作中還真會有問題!

某天我處理了一個工作表格的計算,然後第二天被告知線上有問題,之後被產品小姐姐問話:

  • 為什麼小學生都能做出的小數計算,你們計算機算不了呢?

默哀三秒,產生上面的找到探索,最終找到下面的解決方案。

四 解決問題

返回目錄

下面嘗試通過各種方式來解決浮點數計算的問題。

4.1 toFixed()

返回目錄

toFixed() 方法使用定點表示法來格式化一個數值。

語法:numObj.toFixed(digits)

引數:digits。小數點後數字的個數;介於 0 到 20(包括)之間,實現環境可能支援更大範圍。如果忽略該引數,則預設為 0。

const num = 12345.6789;

num.toFixed(); // '12346':進行四捨五入,不包括小數部分。
num.toFixed(1); // '12345.7':進行四捨五入,保留小數點後 1 個數字。
num.toFixed(6); // '12345.678900':保留小數點後 6 個數字,長度不足時用 0 填充。
(1.23e+20).toFixed(2); // 123000000000000000000.00 科學計數法變成正常數字型別
複製程式碼

toFixed() 得出的結果是 String 型別,記得轉換 Number 型別。

toFixed() 方法使用定點表示法來格式化一個數,會對結果進行四捨五入。

通過 toFixed() 我們可以解決一些問題:

原加減乘數:

console.log(1.0 - 0.9);
// 0.09999999999999998

console.log(0.3 / 0.1);
// 2.9999999999999996

console.log(9.7 * 100);
// 969.9999999999999

console.log(2.22 + 0.1);
// 2.3200000000000003
複製程式碼

使用 toFixed()

// 公式:parseFloat((數學表示式).toFixed(digits));
// toFixed() 精度引數須在 0 與20 之間

parseFloat((1.0 - 0.9).toFixed(10));
// 0.1   

parseFloat((0.3 / 0.1).toFixed(10));
// 3  

parseFloat((9.7 * 100).toFixed(10));
// 970

parseFloat((2.22 + 0.1).toFixed(10));
// 2.32
複製程式碼

那麼,講到這裡,問題來了:

  • parseFloat(1.005.toFixed(2))

會得到什麼呢,你的反應是不是 1.01

然而並不是,結果是:1

這麼說的話,enm...摔!o(╥﹏╥)o

toFixed() 被證明了也不是最保險的解決方式。

4.2 手寫簡易加減乘除

返回目錄

既然 JavaScript 自帶的方法不能自救,那麼我們只能換個思路:

  • 將 JavaScript 的小數部分轉成字串進行計算
/**
 * @name 檢測資料是否超限
 * @param {Number} number 
 */
const checkSafeNumber = (number) => {
  if (number > Number.MAX_SAFE_INTEGER || number < Number.MIN_SAFE_INTEGER) {
    console.log(`數字 ${number} 超限,請注意風險!`);
  }
};

/**
 * @name 修正資料
 * @param {Number} number 需要修正的數字
 * @param {Number} precision 端正的位數
 */
const revise = (number, precision = 12) => {
  return +parseFloat(number.toPrecision(precision));
}

/**
 * @name 獲取小數點後面的長度
 * @param {Number} 需要轉換的數字
 */
const digitLength = (number) => {
  return (number.toString().split('.')[1] || '').length;
};

/**
 * @name 將數字的小數點去掉
 * @param {Number} 需要轉換的數字
 */
const floatToInt = (number) => {
  return Number(number.toString().replace('.', ''));
};

/**
 * @name 精度計算乘法
 * @param {Number} arg1 乘數 1
 * @param {Number} arg2 乘數 2
 */
const multiplication = (arg1, arg2) => {
  const baseNum = digitLength(arg1) + digitLength(arg2);
  const result = floatToInt(arg1) * floatToInt(arg2);
  checkSafeNumber(result);
  return result / Math.pow(10, baseNum);
  // 整數安全範圍內的兩個整數進行除法是沒問題的
  // 如果有,證明給我看
};

console.log('------\n乘法:');
console.log(9.7 * 100); // 969.9999999999999
console.log(multiplication(9.7, 100)); // 970

console.log(0.01 * 0.07); // 0.0007000000000000001
console.log(multiplication(0.01, 0.07)); // 0.0007

console.log(1207.41 * 100); // 120741.00000000001
console.log(multiplication(1207.41, 100)); // 0.0007

/**
 * @name 精度計算加法
 * @description JavaScript 的加法結果存在誤差,兩個浮點數 0.1 + 0.2 !== 0.3,使用這方法能去除誤差。
 * @param {Number} arg1 加數 1
 * @param {Number} arg2 加數 2
 * @return arg1 + arg2
 */
const add = (arg1, arg2) => {
  const baseNum = Math.pow(10, Math.max(digitLength(arg1), digitLength(arg2)));
  return (multiplication(arg1, baseNum) + multiplication(arg2, baseNum)) / baseNum;
}

console.log('------\n加法:');
console.log(1.001 + 0.003); // 1.0039999999999998
console.log(add(1.001, 0.003)); // 1.004

console.log(3.001 + 0.07); // 3.0709999999999997
console.log(add(3.001, 0.07)); // 3.071

/**
 * @name 精度計算減法
 * @param {Number} arg1 減數 1
 * @param {Number} arg2 減數 2
 */
const subtraction = (arg1, arg2) => {
  const baseNum = Math.pow(10, Math.max(digitLength(arg1), digitLength(arg2)));
  return (multiplication(arg1, baseNum) - multiplication(arg2, baseNum)) / baseNum;
};

console.log('------\n減法:');
console.log(0.3 - 0.1); // 0.19999999999999998
console.log(subtraction(0.3, 0.1)); // 0.2

/**
 * @name 精度計算除法
 * @param {Number} arg1 除數 1
 * @param {Number} arg2 除數 2
 */
const division = (arg1, arg2) => {
  const baseNum = Math.pow(10, Math.max(digitLength(arg1), digitLength(arg2)));
  return multiplication(arg1, baseNum) / multiplication(arg2, baseNum);
};

console.log('------\n除法:');
console.log(0.3 / 0.1); // 2.9999999999999996
console.log(division(0.3, 0.1)); // 3

console.log(1.21 / 1.1); // 1.0999999999999999
console.log(division(1.21, 1.1)); // 1.1

console.log(1.02 / 1.1); // 0.9272727272727272
console.log(division(1.02, 1.1)); // 數字 9272727272727272 超限,請注意風險!0.9272727272727272

console.log(1207.41 / 100); // 12.074100000000001
console.log(division(1207.41, 100)); // 12.0741

/**
 * @name 按指定位數四捨五入
 * @param {Number} number 需要取捨的數字
 * @param {Number} ratio 精確到多少位小數
 */
const round = (number, ratio) => {
  const baseNum = Math.pow(10, ratio);
  return division(Math.round(multiplication(number, baseNum)), baseNum);
  // Math.round() 進行小數點後一位四捨五入是否有問題,如果有,請證明出來
  // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math/round
}

console.log('------\n四捨五入:');
console.log(0.105.toFixed(2)); // '0.10'
console.log(round(0.105, 2)); // 0.11

console.log(1.335.toFixed(2)); // '1.33'
console.log(round(1.335, 2)); // 1.34

console.log(-round(2.5, 0)); // -3
console.log(-round(20.51, 0)); // -21
複製程式碼

在這份程式碼中,我們先通過石錘乘法的計算,通過將數字轉成整數進行計算,從而產生了 安全 的資料。

JavaScript 整數運算會不會出問題呢?

乘法計算好後,假設乘法已經沒問題,然後通過乘法推出 加法、減法 以及 除法 這三則運算。

最後,通過乘法和除法做出四捨五入的規則。

JavaScript Math.round() 產生的數字會不會有問題呢、

這樣,我們就搞定了兩個數的加減乘除和四捨五入(保留指定的長度),那麼,裡面會不會有問題呢?

如果有,請例舉出來。

如果沒有,那麼你能不能依據上面兩個數的加減乘除,實現三個數甚至多個數的加減乘除?

五 現成框架

返回目錄

這麼重要的計算,如果自己寫的話你總會感覺惶惶不安,感覺充滿著危機。

所以很多時候,我們可以使用大佬們寫好的 JavaScript 計算庫,因為這些問題大佬已經幫我們進行了大量的測試了,大大減少了我們手寫存在的問題,所以我們可以呼叫別人寫好的類庫。

下面推薦幾款不錯的類庫:

Math.js 是一個用於 JavaScript 和 Node.js 的擴充套件數學庫。

它具有支援符號計算的靈活表示式解析器,大量內建函式和常量,並提供了整合的解決方案來處理不同的資料型別,例如數字,大數,複數,分數,單位和矩陣。

強大且易於使用。

JavaScript 的任意精度的十進位制型別。

一個小型,快速,易於使用的庫,用於任意精度的十進位制算術運算。

一個用於任意精度算術的 JavaScript 庫。

最後的最後,值得一提的是:如果對數字的計算非常嚴格,或許你可以將引數丟給後端,讓後端進行計算,再返回給你結果。

例如涉及到比特幣、商城商品價格等的計算~

六 參考文獻

返回目錄

致敬在 JavaScript 計算這塊領域做了貢獻的大佬,本篇文章大體採用了以下文章的內容,對其進行了個人嘗試和融匯,感謝大佬們的文獻:

如果你想了解更多,歡迎關注 jsliang 的文件庫:document-library


知識共享許可協議
jsliang 的文件庫樑峻榮 採用 知識共享 署名-非商業性使用-相同方式共享 4.0 國際 許可協議進行許可。
基於github.com/LiangJunron…上的作品創作。
本許可協議授權之外的使用許可權可以從 creativecommons.org/licenses/by… 處獲得。

相關文章