0.57*100===56.99999999999999之謎

NO IMAGE

導語 為什麼 0.1 + 0.2 === 0.30000000000000004, 0.3 – 0.2 === 0.09999999999999998 ?

前言

在最近業務開發中, 作者偶遇到了一個與 JavaScript 浮點數相關的 Bug。

這裡就簡單描述下背景: 在提現相關業務時, 會將展示給用戶以元為單位的數值轉化為以分為單位的數值。 例如, 0.57元 轉化為 57 分

轉化方法很簡單

// 小程序代碼 onInput: 監聽Input事件
onInput(e) {
let value = e.target.value;
//限制除數字和小數點以外的字符輸入
if (!/^\d*\.{0,2}\d{0,2}$/.test(value)) {
value = value
.replace(/[^\d.]/g, '')
.replace(/^\./g, '')
.replace(/\.{2,}/g, '.')
// 保留數字小數點後兩位
.replace(/^(.*\..{2}).*$/, '$1');
}
//...
this.setData({
cash: +value * 100  // 乘100, 將元轉化為分
})
}

這段看似沒有問題的代碼, 提交給後臺時, 接口卻返回參數值格式不正確。

最初, 懷疑是正則表達式有疏漏, 但測試了一下沒有問題, 然後就嘗試了用戶輸入的數值 0.57, 卻發現計算值卻出人意料, 也就是題目中的 0.57 * 100 === 56.99999999999999

前端開發同學或多或少都應該看到過0.1 + 0.2 === 0.30000000000000004這個經典問題。 作者當初也抱著好奇的態度看了相關文章, 說來慚愧, 想到自己無論如何也不會開發0.1 + 0.2的業務, 也只是瞭解到了為什麼會是這樣的結果就淺嘗輒止了。

如今踩了坑, 只能說是自己跳進了當年挖的坑, 那今天就將這個坑填上。

本文文章會講述以下幾個問題, 已經熟悉同學就可以不用看啦。

  1. 為什麼 0.1 + 0.2 === 0.30000000000000004
  2. 為什麼 0.57 * 100 === 56.99999999999999
  3. 為什麼 0.57 * 1000 === 570

Why 0.1 + 0.2 === 0.30000000000000004 ?

要解答這個問題始終繞不過JavaScript中最基礎也是最核心的浮點數的格式存儲。 在JS中, 無論整數還是小數都是Number類型, 它的實現遵循IEEE 754, 是標準的Double雙精度浮點數, 使用固定的64位來表示。

看到這裡你可能就不想看下去了。好好好, 那就後面再說, 這裡就用大白話簡單講解, 詳細內容在文章後面閱讀。

實際上, JS中的數字都會轉化為二進制存儲下來, 由於數字存儲限定了64位, 但現實世界中, 數字是無窮的, 所以一定會有數字超出這個存儲範圍。超出這個範圍的數字在存儲時就會丟失精度。

同時, 我們都知道, 整數十進制轉二進制時, 是除以二去餘數, 這是可以除盡的! 但我們可能不知道的是, 小數十進制轉化為二進制的計算方法是, 小數部分*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
...
後面就是循環了

到這裡, 我們就可以發現一些端倪了

// 使用toString(2), 將10進制輸出為二進制的字符串
0.1.toString(2);
// "0.00011001100110011001100110011001100110011001100110011001100..."
0.2.toString(2);
// "0.001100110011001100110011001100110011001100110011001100110011..."
// 二進制相加結果, 由於超過精度, 取52位, 第53位舍0進1
> "0.010011001100110011001100110011001100110011001100110011,1"
// 最後存儲下來的結果是
const s = "0.010011001100110011001100110011001100110011001100110100"
// 用算法處理一下。
a = 0;
s.split('').forEach((i, index) => { a += (+i/Math.pow(2, index+1))});
// a >> 0.30000000000000004

到這裡, 0.1 + 0.2 === 0.30000000000000004

以上論述過程仍有一些疑惑之處

  1. 為什麼小數轉化為二進制後, 52位以後就超過精度了?

這些都與64位雙精度浮點數是如何存儲的有關, 我們放到最後再說。

Why 0.57 * 100 === 56.99999999999999 ?

Why 0.57 * 1000 === 570 ?

閱讀完上面一節, 對小數的乘法我們也可以有一些自己的猜測了。

0.57這個數值在存儲時, 本身的精度不是很準確, 我們用toPrecision這個方法可以獲取小數的精度。

0.57.toPrecision(55)
// "0.5699999999999999511501869164931122213602066040039062500"

作者最初的想法有點愚蠢, 0.57的實際值是0.56999.., 那0.57 * 100也就是0.56999... * 100, 那結果就是56.99999999999999啦。

而此時, 路總問了我一個問題, 為什麼0.57 * 1000 === 570 而不是 569.99999..., 不求甚解的我只能先回答”應該是精度丟失吧”

然而, 我”小小的眼睛裡充滿了大大的疑惑”…

後來想了下, 其實我們都知道, 計算機的乘法實際上是累加計算, 並不是我們想的按位相乘。

// 偽代碼
(0.57) * 100
= (0.57) * (64 + 32 + 4)
= (0.57二進制) * (2^6 + 2^5 + 2^2)
= 0.57二進制 * 2^6 + 0.57二進制 * 2^5 + 0.57 * 2^2

由於精度丟失, 這個是真的丟失啦, 在二進制轉十進制時, 結果就是56.99999…了

同理, (0.57 * 1000)也不是簡單的乘, 也是累加起來的, 只是最後精度丟失時舍0進1, 結果就是570而已。

解決問題

對於大部分業務來講, 確定數字精度後, 使用Math.round就可以了。
例如本文最初遇到的BUG

const value = Math.round(0.57 * 100);

而我們不太確定精度的浮點數運算, 通用的解決方案都是將小數轉化為整數, 進行計算後, 再轉化為小數就好了。

以下是引用[1]

/**
* 精確加法
*/
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;
}

當然已經有成熟的工具庫可以使用了, 例如Math.js, BigDecimal.js, number-precision等等, 使用哪個任君挑選

IEEE754標準下的浮點數存儲

其實下面這段內容來自於Wiki

64位如圖進行劃分

0.57*100===56.99999999999999之謎

第0位: 是符號的標誌位
第1-11位: 指數位
第12-63位: 尾數

0.57*100===56.99999999999999之謎

0.1為例, 0.1的二進制是0.00011001100110011001100110011001100110011001100110011001100...

那麼, 首先, 該數是正數, 標誌位 sign = 0

其次, 將小數轉化為科學計數法, 指數位-4即exponent = 2 ^10 – 4 = 1019

1.1001100110011001100110011001100110011001100110011001100... * 2^-4

由於科學計數法, 第一個數始終是1, 所以可以忽略存儲, 只要存後面的52位就可以了

如果超過了52位, 就是對第53位舍0進1, 結果也就是100110011001100110011001100110011001100110011001101了。

Double精度的浮點數存儲大概就是這個樣子了, 這也解答了上述的疑惑。

以上就是本文的全部內容了。

題外: 好讀書,不求甚解;每有會意,便欣然忘食

參考

[1]JavaScript 浮點數陷阱及解法


0.57*100===56.99999999999999之謎

如果覺得文章對您有幫助,那就關注一下【IVWEB社區】公眾號吧~

相關文章

基於Webpack4的Vue移動端開發環境搭建篇

CSS3Transition過渡動畫用法介紹

Java線程池的四種用法與使用場景

如何真正寫好代碼註釋—現代JavaScript教程