JS 的浮點數精準度問題 & 十進位小數轉二進位小數


Posted by Wangpoching on 2022-02-06

前言

原本打算先寫 React 的測試相關的文章,不過沒想到碰上了浮點數精度問題,所以決定先寫一篇簡單探討 JavaScirpt 中浮點數精準度問題的文章!

事情是這樣子的,有一個簡單的加法函式 add,會回傳輸入的相加結果。

function add(...args) {
return args.reduce((a,b) => {
return a + b
}, 0)
}

我們簡單寫個測試:

import { add } from './utils'

test('add function', () => {
  expect(add(0.1, 0.2)).toBe(0.3)
})

結果輸出是這個樣子:

img

為甚麼 0.1 + 0.2 會輸出 0.0000...304???

IEEE754

要談到為甚麼會發生這件事之前,我們必須先認識 JS 中儲存小數的標準: IEEE754

img

JS 使用 32 個 bits 來表示浮點數,其中可以分成三個區塊:

  1. Sign bits: 用來表示該浮點數的正負 (1 bit)
  2. Exponent: 用來表示該浮點數正規化後的次方數 (8 bits)
    正規化:讓所有的實數表示成 1.xxxx{2} × 2 的 n 次方
    例如:111.1{2} 正規化後會變成 1.111{2} × 2 的 -2 次方
  3. Fraction: 用來儲存小數位數 (23 bits)
    註: {2} 代表數字是以二進位表示

十進位小數轉二進位小數(輾轉相乘)

在 JS 裡,浮點數是以二進位的方式搭配 IEEE754 標準來儲存的,所以在了解 IEEE754 以後接著要介紹十進位小數轉二進位小數的方法。

首先試著思考 0.625 這個數字,我們看能不能把它給湊成二進位表示。

0.625 = 0.5 + 0.125 = 2^(-1) + 2^(-3) = 0.101{2}

所以 0.625 以二進位可以表示成 0.101{2}。 不過每次都這樣子湊也太麻煩了吧! 有甚麼規則可循嗎?

照順序湊

從剛剛的範例裡面我們可以知道我們希望把 0.625 拆成 1/2, 1/4, 1/8... 的相加值。那我們來試著用這個想法吧!

0.625 = 0.625 × 2 ÷ 2 = 1.25 ÷ 2 = (1 + 0.25) ÷ 2 = 1/2 + (0.25 ÷ 2)

因為 1/2, 1/4, 1/8 的分子都是 1,為了湊出 1,我們把 0.625 乘以 2,讓它可以拆成 1 加上 0.25,接著利用除法的分配律分離出了 1/2!

0.25 ÷ 2 = (0.25 × 2 ÷ 2) ÷ 2 = 0.5 ÷ 4

我們把剩下的 0.25 依樣畫葫蘆,想湊出 1/4,可惜這次只有 0.5 小於 1

0.5 ÷ 4 = (0.5 × 2 ÷ 2) ÷ 4 = 1 ÷ 8 = 1/8

再依樣畫葫蘆,這次想湊出 1/8,成功了,而且沒有剩下的值。

所以 0.625 = 1/2 + 1/8 = 0.101{2}

這個方法靠著有系統的不斷將十進位小數乘以 2,只要大於 1 的話就可以利用除法的分配律拿出 2 的次方,最後直到 10 進位小數被取到剩下 0 為止。

輾轉相乘

將剛剛的方式研發成算則會像這樣子:

0.625 × 2 = 1.250
0.250 × 2 = 0.500
0.500 × 2 = 1.000 (小數點後全部為 0 結束)

取整數位依序往小數點往後填,得到 0.625 = 0.101{2}

模擬 IEEE754 儲存二進位小數

接下來用輾轉相乘法練習 0.1 以及 0.2 轉二進位

0.1

0.1 × 2 = 0.2
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2

等等, 0011 怎麼會一直重複? 暫且先表示成 0.1 = 0.00011[0011]...{2}

接著看看 IEEE754 如何表示。

0.1 = 0.00011[0011]...{2} = 1.1[0011]...{2} × 2^(-4)

  • Sign bits: 0
  • Exponent bits: 2^(8-1)+(-4) = 127+(-4) = 123 = 1111011{2} = 0111 1011 (前面多加一個 0 是為了補齊 8 bits)
  • Mantissa bits: 1 0011 0011 … (無限循環)
    0 捨 1 入到 23 位是:1 0011 0011 0011 0011 0011 001 + 1 = 1 0011 0011 0011 0011 0011 01

結果是: 0 0111 1011 1 0011 0011 0011 0011 0011 01

轉回十進位: 2^(-4)+2^(-5)+2^(-8)+2^(-9)+2^(-12)+2^(-13)+2^(-16)+2^(-17)+2^(-20)+2^(-21)+2^(-24)+2^(-25)+2^(-27) = 0.100000001490116119384765625

哭了,0.1 經過存儲以後變成了 0.100000001490116119384765625

0.2

0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2

又無限一直重複了。 暫且先表示成 0.2 = 0.0011[0011]...{2}

接著看看 IEEE754 如何表示。

0.2 = 0.0011[0011]...{2} = 1.1[0011]...{2} × 2^(-3)

  • Sign bits: 0
  • Exponent bits: 2^(8-1)+(-3) = 127+(-3) = 124 = 1111100{2} = 0111 1100
  • Mantissa bits: 1 0011 0011 … (無限循環)

結果是: 0 0111 1100 1 0011 0011 0011 0011 0011 01

轉回十進位: 2^(-3)+2^(-4)+2^(-7)+2^(-8)+2^(-11)+2^(-12)+2^(-15)+2^(-16)+2^(-19)+2^(-20)+2^(-23)+2^(-24)+2^(-26) = 0.20000000298023223876953125

浮點數精準度

到了這裡我們知道不是所有的十進位小數都可以被二進位小數精確的表示,而這也是前言裡面為甚麼 0.1 + 0.2 !== 0.3 的原因了。

最後再來點算點有趣的吧! 來算算看有效位數,我們知道 IEEE754 的有效位數是 23 位,不過要記得這裡的有效位數是二進位制的,如果換算成十進位

2^23 = 8388608

所以說十進位大概只有 6 ~ 7 位有效數字

不過要記得的是有效位數以及可表示的數的範圍是不一樣的,可表示的數的範圍取決於次方數的極限,所以一樣以 IEEE754 來說,數的範圍在 10^(-37) ~ 10^38 之間。

在 JavaScript 中測試小數的運算

回到最一開始的問題,那要怎麼測試浮點數的相加呢? 其實可以像下面這樣寫:

import { add } from './utils'

test('add function', () => {
  expect(abs(add(0.1, 0.2) - 0.3) < Number.EPSILON).toBe(true)
})

Number.EPSILON 在 MDN 的解釋是這樣的:

The Number.EPSILON property represents the difference between 1 and the smallest floating point number greater than 1.

然後測試就過啦!!

img


#2進位 #十進位 #輾轉相乘 #浮點數 #浮點數誤差 #IEEE754







Related Posts

菜鳥切版 邊框

菜鳥切版 邊框

一個資淺工程師年末的自我省視

一個資淺工程師年末的自我省視

There's no hierarchy of pain

There's no hierarchy of pain


Comments