前言
我們常聽到的概數法不外乎「無條件進位」、「無條件捨去」以及最常被使用的「四捨五入法」,在 JavaScript 中,他們分別是 Math.ceil()、Math.floor() 和 Math.round(),實際使用時他們只能取到最接近的整數,若情境是「取概數到小數點底下第 N 位」,則需要再多做處理。
而在其他文章中還有提到 Number.toFixed() 函式,雖然常被拿來處理小數,而且參數也可以代入小數點位數,看似能夠直接解決問題,但他並不能被歸類在概數函式,原因有二:
1. 它回傳的是字串而不是數字,此函式目的是將數字格式化成字串
2. 因受到浮點數誤差影響,其結果無法歸納為常見的概數法,不可拿來做概數運算(以下說明)
但是,看似沒什麼問題的概數世界其實暗藏玄機,Math.round() 並不符合 IEEE 754 的標準,舉例來說, 2.5 在 IEEE 754 取至整數會是 2,而 Math.round() 的結果卻是 3,後者較符合我們常見的四捨五入算法,這兩者之間有什麼區別呢?
跑過一次 Number.toFixed() 演算法
我們先來看 Spec 中, Number.toFixed() 函式的演算法步驟:
參數處理
前半部的步驟中,大多是處理資料型態,這裡我們看到:
- f 其實是有一個範圍的(0–100)
- x 超過 10²¹ 就會直接被 `ToString` 成科學記號並回傳(參考 NumberToString)
- 演算法最後會回傳 s 與 m 的字串相接,s 是指正負符號,而 m 是數字部分,因此這裡判斷數字的正負並指定 s
- f 沒指定就是 0,取到整數
概數與字串處理
第九點是演算法的大重點,關於如何選擇概數、字串填補,以及決定小數點位置,我們以 (30.15).toFixed(1) 作為例子,一步一步來跑過這部分的演算法。(x = 30.15, f = 1)
Step 9.a
- 在選擇向上還是向下取整的決定上,JavaScript Spec 指定了「選比較大的那個數」
- n 必須是最近似 301.5 的整數,因此會是 302 或是 300,依照 Spec 的標準會是 302。
Step 9.b
- 這裡初始化 m, m 是回傳字串的數字部分。
- 如果 n 是 0,代表取的位數比數字本身還大(像是 (0.005).toFixed(1)),因此 m 字串會被指定為 0,其他狀況就是 n 直接變為字串。
Step 9.c
- 初始化一個資料 k ,它是 m 的長度。
- 如果我們要取的位數 f 比 k 還大,則我們要補 0 在 m 的最後面,例如 (10.5).toFixed(4) 會得到 10.5000。
Step 9.d
- 決定小數點在 m 字串的位置,它會在第 k-f 位的後方,在我們的例子中, k-f = 2 ,因此會得到 30.2 ,這結果也符合我們四捨五入的運算。
Step 10
- 回傳 s 與 m 的字串相接,大功告成,結果就是 30.2。
事與願違
Errr⋯⋯沒錯,在處理浮點數的問題時,事情總是沒有想像中的單純,由於浮點數精確度的關係,這個 Number.toFixed() 的結果有時會壞掉,甚至難以預期。
所以前面說過,不要用 Number.toFixed() 來處理概數,顯示格式化字串的功能是沒問題,但不要期待他會給出邏輯一致的結果,他的好兄弟 Number.toPrecision() 也是一樣的毛病,接下來我們來看看 IEEE 754 定義的概數法(correct rounding)。
IEEE 754 裡的概數
JavaScript 裡的 Number 型態幾乎完整實作了 IEEE 754,包括 NaN、INF 等,都與 IEEE 定義如出一轍,而 IEEE 754 定義的概數方法有五種,其中三種「直接概數法 (Direct rounding) 」不在此次範圍,尚不討論,我們專注於兩種「最接近值法(Nearest value rounding)」,這兩種方法討論的都是在取概數時,若遇到 5 時該向上或是向下取整。
取至最遠離零的數
- 以零作為原點,取概數時選擇較遠離原點的數
- 例如:2.5 會取整為 3,-2.5 會取整為 -3。
- 優點:提升執行效率,實作者只需要在第一個 bit 判斷數字的符號(代表數字的正負)後,就可決定在遇到 5 時該向上還是向下取整。
接近偶數法
- 此為 IEEE 754 的預設方法
- 又稱為「銀行概數法」(Banker’s rounding),取概數時會選擇最接近的偶數。
- 例如: 2.5 會取整為 2(向下), 3.5 會取整為 4 (向上)。
- 它的優點是能夠消弭在取概數時產生的偏差值,假設我們今天要取一系列數的概數,若採取遇到 5 一律向上取整的方法,則整體數字的值會上升,偏離原數的平均值。
const numbers = [0.5, 1.5, 2.5, 3.5, 4.5]const roundedNumbers = round(numbers)
// 遇到 5 一律向上取整
// [1, 2, 3, 4 ,5]const evenRoundedNumbers = roundToEven(number)
// 遇到 5 取整到最接近的偶數
// [0, 2, 2, 4, 4]mean(numbers)
// 原數平均數:(0.5 + 1.5 + 2.5 + 3.5 + 4.5) / 5 = 2.5mean(roundedNumbers)
// 向上取整法的平均數:(1 + 2 + 3 + 4 + 5) / 5 = 3mean(evenRoundedNumbers)
// 接近偶數法的平均數:(0 + 2 + 2 + 4 + 4) / 5 = 2.4
結論
- 雖然在顯示數字上,概數的精確度並不是太大的問題,但還是不要對 Number.toFixed() 抱有錯誤的期待,以免在使用上出現預期外的結果。
- JavaScript 裡沒有內建「最接近偶數法」的實作,網路上 math.js 這個 Library 雖然能夠指定位數,但仍然是向上取整。
Ref
- IEEE 754 Standard
- JavaScript Spec
- https://wiki.c2.com/?BankersRounding