1. 程式人生 > >Js 與浮點數

Js 與浮點數

同步發表在我的部落格:jmingzi

當你學習一個知識點沒有方向時,可以嘗試以解決問題的角度來理解它。

例如這個知識點我們可以從以下問題開始:

  • 你看的到 1 真的是整數 1 嗎?
  • 為什麼0.1 + 0.2 得到的是 0.30000000000000004 而不是 0.30000000000000004999 ?
  • 為什麼最大安全數是 2^53 - 1 ?
  • 如何避免精度問題?
  • 建構函式 Number 的一些靜態屬性

問題一

我們需要知道 js 中沒有真正的整數,我們看到的數值都是 v8 引擎省略精度後的結果。在 ecma-262 規範中並沒有說明該如何省略精度,所以如果換個解析引擎,可能又是另外一種結果了。

1..toPrecision(4) === '1.000'

我們可以用 toPrecision 來獲取數值精度的字串表示,所以對 js 中的數你得有 “橫看成嶺側成峰” 的感覺。

問題二

由問題一我們知道精度是引擎處理的結果,那麼這個問題也是同樣的道理,0.1 + 0.2 如果放大精度來看,得到的當然是

(0.1 + 0.2)..toPrecision(21) === '0.300000000000000044409' 

猜測:小數部分的精度預設是非 0 的 17 位,如果末尾是 0 則繼續省略。接下來,我們從驗證問題三開始從原理入手。

問題三

javascript 的浮點數採用的是 IEEE 754 雙精度 64 位表示,IEEE 754 規定了四種浮點數的表示方式,雙精度 64 位是其中的一種。

64 位二進位制組成:

 

  • 第 1 位符號位,用 S 表示,0 代表正數,1 代表負數
  • 第 2 - 12 位指數位,用 E 表示,即 2^11 = 2048,曲值範圍是 0 ~ 2047,由於是雙精度,所以指數部分有正數也有負數,取中值 1023 分隔,即指數值為 E - 1023
  • 第 13 - 64 位尾數位,用 M 表示

例如數字 1 用 二進位制表示也是 “1”,如果我們填充 64 位二進位制,表示應該是怎樣的呢?

  • 符號 S = 0
  • 指數 E - 1023 = 0,E = 1023
  • 尾數 M = 52

那麼實際我們看到的二進位制儲存應該是這樣的:

0 01111111111 0000...0000

用線上轉換工具轉換後如下圖,可以驗證結果的正確性:

 

 

http://www.binaryconvert.com/result_double.html

我們再用 0.1 + 0.2 驗證下:

0.1.toString(2) 
// 0.00011001100...11010

0.1 的二進位制 64 位轉成科學記數法表示:1.10011001100...*10^-4,也就是說

  • 符號 S = 0
  • 指數 E = 1023 - 4 = 1019
  • 尾數 M = 10011001100...11010 我們可以繼續用線上轉換的網址驗證下結果:

 

 

我們可以發現,其實 0.1 的尾數是 1100 不斷迴圈的,但是我們看到的最後 4 位是 1010 這是由於尾數只能儲存 52 位,多餘的部分會被捨棄,捨棄規則是 IEEE 754 規範所定義的:

  • 舍入到最接近:舍入到最接近,在一樣接近的情況下偶數優先(Ties To Even,這是預設的舍入方式),會將結果舍入為最接近且可以表示的值,但是當存在兩個數一樣接近的時候,則取其中的偶數(在二進位制中式以0結尾的)
  • 朝+∞方向舍入
  • 朝-∞方向舍入
  • 朝 0 方向舍入

另外,規範還約定,由於二進位制的科學記數法永遠是 1.幾 開頭,所以將 1 省略,這樣尾數就有 53 位來表示。所以 0.1 的二進位制尾數部分:11001100...11001 這樣 53 位,最後一位 1 向偶數舍入即進 1,得到 1010,這樣得到的數其實是比真實的 0.1 要大的。

同理,我們檢視 0.2 的表示:0.00110011001100...11010,可以得到尾數部分其實是一樣的,僅僅是指數少了 1 位。

 

我們再來看看問題三,為什麼最大安全整數是 2^53 - 1?我們可以反過來驗證為什麼 2^53 已經不再“安全”了。

 

由問題一我們知道正指數最大為 2047 - 1023 = 1024,為什麼不是 2^1024 呢?由上我們知道尾數其實是可以有 53 位表示的(省略的 1 位),即

// Math.pow(2, 53).toString(2)
2^53 用二進位制表示:1000...000,1 個 1,53 個 0 
// 此時的尾數最多為 53 位,第 54 位 0 會被捨去

2^53-1 用二進位制表示:1111...111,53 個 1
2^53+1 用二進位制表示:1000...0001,1 個 1,52 個 0,1 個 1 

其中,2^53 + 1 由於尾數最多為 53 位,所以必須舍掉第 54 位 1,根據舍入規則,向偶數舍入,所以舍掉第 54 位 1,不進 1。於是得到

2^53 === 2^53+1

 

也就是說從 2^53 開始就不能唯一表示一個數了,所以才說 2^53-1 是最大的安全數。

問題四

前端避免精度的場景就是展示某個價格,例如下面的公式:

展示價格 = 商品價格 * 數量 + 總價 * 服務費比例 - 優惠券價格

我們常用的方法有 Number.toFixed()、Number.parseFloat()、Math.round(),它們的區別是什麼,弄清楚後才能知道如何使用它們。

Number.toFixed(digits) 返回指定位數的字串表示,會進行四捨五入。例如

1.005.toFixed(2) // 1.00

很顯然不符合我們需求,為什麼會這樣呢?因為 1.005 這個數在 64 位二進位制儲存時是不能完全表示這個數的,我們放大精度看看

1.005.toPrecision(17)
// 1.0049999999999999

所以四捨五入的時候就將 499...99 舍掉了。

還記得問題三麼?0.300000000000000044409 ,猜測的是是小數位 17 位,超過 17 位的部分會被四捨五入。因為做精度運算時都會做四捨五入:

1.005.toPrecision(16)
// 1.005000000000000

1.005.toPrecision(17)
// 1.0049999999999999

1.005.toPrecision(18)
// 1.00499999999999989

所以使用 toFixed 去做展示運算是不可靠的。

Number.parseFloat === parseFloat ,將字串轉換為浮點數表示,很顯然轉換後顯示出來也會有精度問題,因為精確到哪一位呢?

我上面猜測說是:“小數部分的精度預設是非 0 的 17 位”,這是不準確的,例如:

1.005 * 100
// 100.49999999999999
100.49999999999999.toPrecision(20)
// 100.49999999999998579
'49999999999999'.length
// 14 並不是 17

所以 parseFloat 後的結果和我們直接寫出來看到的數字是一樣的,並不能夠使用它直接參與計算。

Math.round() 返回一個數字四捨五入後最接近的整數,所以我們一般將浮點數放大為精度位的整數後再使用 Math.round() 得到四捨五入後的整數,再縮小精度位。

例如對於價格類,精度為 2,我們可以先乘 100 做運算後再除 100,此時得到的很可能是個精度位很長的數,我們只需要在展示時乘 100,Math.round 後再除 100 即可。

所以結論就是對於浮點數的計算,先放大,再做計算,計算完成後需要展示精度,可以使用 Math.round 四捨五入後,再縮小即可。

對於不需要做計算的浮點數直接展示,我們可以先 toPrecision 放大精度,再使用 toFixed() 到指定的精度。前提是放大的精度足夠大,最好是 17 。

function round(num, precision) {
  const base = Math.pow(10, precision)
  return Math.round((num.toPrecision(17) * base).toFixed(1)) / base
}
round(1.005, 2)
// 1.01

問題五

Number 建構函式擁有的靜態屬性如下(負方向已忽略):

// 最大能表示的值,無限接近於 2^1024 
Number.MAX_VALUE
// 最大安全數 2^53 - 1
Number.MAX_SAFE_INTEGER
// 正無窮大 2^1024
Number.POSITIVE_INFINITY === Infinity
// 非數值
Number.NaN 等同於 NaN

關於 Infinity 和 NaN 需要注意的是

  • Infinity === Infinity
  • NaN !== NaN
  • Infinity / Infinity = NaN
  • 0 * Infinity = NaN
  • 任何數 和 NaN 運算結果都是 NaN
  • 任何數 / Infinity = 0
  • Infinity * Infinity = Infinity

關注我的公眾號,獲取更多幹貨~