新四季網

JavaScript浮點數陷阱0.10.2(JavaScript浮點數陷阱0.10.2)

2023-04-13 11:32:53

眾所周知,JavaScript 浮點數運算時經常遇到會 0.000000001 和 0.999999999 這樣奇怪的結果,如 0.1 0.2=0.30000000000000004、1-0.9=0.09999999999999998,很多人知道這是浮點數誤差問題,但具體就說不清楚了。本文幫你理清這背後的原理以及解決方案,還會向你解釋JS中的大數危機和四則運算中會遇到的坑。

浮點數的存儲

首先要搞清楚 JavaScript 如何存儲小數。和其它語言如 Java 和 Python 不同,JavaScript 中所有數字包括整數和小數都只有一種類型 — Number。它的實現遵循 IEEE 754 標準,使用 64 位固定長度來表示,也就是標準的 double 雙精度浮點數(相關的還有float 32位單精度)。計算機組成原理中有過詳細介紹,如果你不記得也沒關係。

這樣的存儲結構優點是可以歸一化處理整數和小數,節省存儲空間。

64位比特又可分為三個部分:

符號位S:第 1 位是正負數符號位(sign),0代表正數,1代表負數指數位E:中間的 11 位存儲指數(exponent),用來表示次方數尾數位M:最後的 52 位是尾數(mantissa),超出的部分自動進一舍零

實際數字就可以用以下公式來計算:

注意以上的公式遵循科學計數法的規範,在十進位是為0<M<10,到二進行就是0<M Math.pow(2, 1023)8.98846567431158e 307> Math.pow(2, 1024)Infinity

那麼對於 (2^53, 2^63) 之間的數會出現什麼情況呢?

(2^53, 2^54) 之間的數會兩個選一個,只能精確表示偶數(2^54, 2^55) 之間的數會四個選一個,只能精確表示4的倍數

... 依次跳過更多2的倍數

下面這張圖能很好的表示 JavaScript 中浮點數和實數(Real number)之間的對應關係。我們常用的 (-2^53, 2^53) 只是最中間非常小的一部分,越往兩邊越稀疏越不精確。

在淘寶早期的訂單系統中把訂單號當作數字處理,後來隨意訂單號暴增,已經超過了 9007199254740992,最終的解法是把訂單號改成字符串處理。

要想解決大數的問題你可以引用第三方庫 bignumber.js,原理是把所有數字當作字符串,重新實現了計算邏輯,缺點是性能比原生的差很多。所以原生支持大數就很有必要了,現在 TC39 已經有一個 Stage 3 的提案 proposal bigint,大數問題有望徹底解決。在瀏覽器正式支持前,可以使用 Babel 7.0 來實現,它的內部是自動轉換成 big-integer 來計算,要注意的是這樣能保持精度但運算效率會降低。

toPrecision vs toFixed

數據處理時,這兩個函數很容易混淆。它們的共同點是把數字轉成字符串供展示使用。

注意在計算的中間過程不要使用,只用於最終結果。

不同點就需要注意一下:

toPrecision 是處理精度,精度是從左至右第一個不為0的數開始數起。toFixed 是小數點後指定位數取整,從小數點開始數起。

兩者都能對多餘數字做湊整處理,也有些人用 toFixed 來做四捨五入,但一定要知道它是有 Bug 的。

如:1.005.toFixed(2) 返回的是 1.00 而不是 1.01。

原因: 1.005 實際對應的數字是 1.00499999999999989,在四捨五入時全部被捨去!

解決方案

回到最關心的問題:如何解決浮點誤差。首先,理論上用有限的空間來存儲無限的小數是不可能保證精確的,但我們可以處理一下得到我們期望的結果。

數據展示類

當你拿到 1.4000000000000001 這樣的數據要展示時,建議使用 toPrecision 湊整並 parseFloat 轉成數字後再顯示,如下:

parseFloat(1.4000000000000001.toPrecision(12)) === 1.4 // True

封裝成方法就是:

function strip(num, precision = 12) { return parseFloat(num.toPrecision(precision));}

為什麼選擇 12 做為默認精度?這是一個經驗的選擇,一般選12就能解決掉大部分0001和0009問題,而且大部分情況下也夠用了,如果你需要更精確可以調高。

數據運算類

對於運算類操作,如 -*/,就不能使用 toPrecision 了。正確的做法是把小數轉成整數後再運算。以加法為例:

/** * 精確加法 */function add(num1, num2) { const num1Digits = (num1.toString.split('.')[1] || '').length; const num2Digits = (num2.toString.split('.')[1] || '').length; const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits)); return (num1 * baseNum num2 * baseNum) / baseNum;}

以上方法能適用於大部分場景。遇到科學計數法如 2.3e 1(當數字精度大於21時,數字會強制轉為科學計數法形式顯示)時還需要特別處理一下。

number-precision(github 上可以搜索)

完美支持浮點數的加減乘除、四捨五入等運算。非常小只有1K,遠小於絕大多數同類庫(如Math.js、BigDecimal.js),100%測試全覆蓋,代碼可讀性強,不妨在你的應用裡用起來!

/** * 把錯誤的數據轉正 * strip(0.09999999999999998)=0.1 */function strip(num: number, precision = 12): number { return parseFloat(num.toPrecision(precision));}/** * Return digits length of a number * @param {*number} num Input number */function digitLength(num: number): number { // Get digit length of e const eSplit = num.toString.split(/[eE]/); const len = (eSplit[0].split('.')[1] || '').length - ( (eSplit[1] || 0)); return len > 0 ? len : 0;}/** * 把小數轉成整數,支持科學計數法。如果是小數則放大成整數 * @param {*number} num 輸入數 */function float2Fixed(num: number): number { if (num.toString.indexOf('e') === -1) { return Number(num.toString.replace('.', '')); } const dLen = digitLength(num); return dLen > 0 ? strip(num * Math.pow(10, dLen)) : num;}/** * 檢測數字是否越界,如果越界給出提示 * @param {*number} num 輸入數 */function checkBoundary(num: number) { if (num > Number.MAX_SAFE_INTEGER || num 0) { return times(times(num1, num2), others[0], ...others.slice(1)); } const num1Changed = float2Fixed(num1); const num2Changed = float2Fixed(num2); const baseNum = digitLength(num1) digitLength(num2); const leftValue = num1Changed * num2Changed; checkBoundary(leftValue); return leftValue / Math.pow(10, baseNum);}/** * 精確加法 */function plus(num1: number, num2: number, ...others: number[]): number { if (others.length > 0) { return plus(plus(num1, num2), others[0], ...others.slice(1)); } const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2))); return (times(num1, baseNum) times(num2, baseNum)) / baseNum;}/** * 精確減法,類似於加法 */function minus(num1: number, num2: number, ...others: number[]): number { if (others.length > 0) { return minus(minus(num1, num2), others[0], ...others.slice(1)); } const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2))); return (times(num1, baseNum) - times(num2, baseNum)) / baseNum;}/** * 精確除法 */function divide(num1: number, num2: number, ...others: number[]): number { if (others.length > 0) { return divide(divide(num1, num2), others[0], ...others.slice(1)); } const num1Changed = float2Fixed(num1); const num2Changed = float2Fixed(num2); checkBoundary(num1Changed); checkBoundary(num2Changed); return times((num1Changed / num2Changed), Math.pow(10, digitLength(num2) - digitLength(num1)));}/** * 四捨五入 */function round(num: number, ratio: number): number { const base = Math.pow(10, ratio); return divide(Math.round(times(num, base)), base);}

,
同类文章
葬禮的夢想

葬禮的夢想

夢見葬禮,我得到了這個夢想,五個要素的五個要素,水火只好,主要名字在外面,職業生涯良好,一切都應該對待他人治療誠意,由於小,吉利的冬天夢想,秋天的夢是不吉利的
找到手機是什麼意思?

找到手機是什麼意思?

找到手機是什麼意思?五次選舉的五個要素是兩名士兵的跡象。與他溝通很好。這是非常財富,它擅長運作,職業是仙人的標誌。單身男人有這個夢想,主要生活可以有人幫忙
我不怎麼想?

我不怎麼想?

我做了什麼意味著看到米飯烹飪?我得到了這個夢想,五線的主要土壤,但是Tu Ke水是錢的跡象,職業生涯更加真誠。他真誠地誠實。這是豐富的,這是夏瑞的巨星
夢想你的意思是什麼?

夢想你的意思是什麼?

你是什​​麼意思夢想的夢想?夢想,主要木材的五個要素,水的跡象,主營業務,主營業務,案子應該抓住魅力,不能疏忽,春天夢想的吉利夢想夏天的夢想不幸。詢問學者夢想
拯救夢想

拯救夢想

拯救夢想什麼意思?你夢想著拯救人嗎?拯救人們的夢想有一個現實,也有夢想的主觀想像力,請參閱週宮官方網站拯救人民夢想的詳細解釋。夢想著敵人被拯救出來
2022愛方向和生日是在[質量個性]中

2022愛方向和生日是在[質量個性]中

[救生員]有人說,在出生88天之前,胎兒已經知道哪天的出生,如何有優質的個性,將走在什麼樣的愛情之旅,將與生活生活有什么生活。今天
夢想切割剪裁

夢想切割剪裁

夢想切割剪裁什麼意思?你夢想切你的手是好的嗎?夢想切割手工切割手有一個真正的影響和反應,也有夢想的主觀想像力。請參閱官方網站夢想的細節,以削減手
夢想著親人死了

夢想著親人死了

夢想著親人死了什麼意思?你夢想夢想你的親人死嗎?夢想有一個現實的影響和反應,還有夢想的主觀想像力,請參閱夢想世界夢想死亡的親屬的詳細解釋
夢想搶劫

夢想搶劫

夢想搶劫什麼意思?你夢想搶劫嗎?夢想著搶劫有一個現實的影響和反應,也有夢想的主觀想像力,請參閱週恭吉夢官方網站的詳細解釋。夢想搶劫
夢想缺乏缺乏紊亂

夢想缺乏缺乏紊亂

夢想缺乏缺乏紊亂什麼意思?你夢想缺乏異常藥物嗎?夢想缺乏現實世界的影響和現實,還有夢想的主觀想像,請看官方網站的夢想組織缺乏異常藥物。我覺得有些東西缺失了