一、背景
最近做 dashborad 圖表時,涉及計算小數且四舍五入精確到 N 位。后發現 js 算出來的結果跟我預想的不一樣,看來這里面並不簡單……
二、JS 與 精度
1、精度處理
首先明確兩點:
- 1、小數才會涉及精度的概念
- 2、小數的(存儲和)運算涉及 JS 的
精度處理
在現實中,我們運算小數,不會出現任何問題。但是 JS (編程語言)里,卻不是這樣。
2、精度丟失
例如,在 JS 里執行:
0.1 + 0.2
0.30000000000000004
0.3 - 0.1
0.19999999999999998
0.1 * 0.1
0.010000000000000002
0.3 / 0.1
2.9999999999999996
可以看出,JS 運算小數的結果,並不是我們預想的那樣。這就是精度丟失的問題。
(1)問:精度丟失會引發什么問題?
答:
- 1、讓判斷等於(
===)的邏輯出錯。比如讓0.1 + 0.2 === 0.3為false - 2、讓本來可以預想到的結果精度變的特別大,小數點后位數特別長。比如若要前端顯示,會特別難看。
(2)問:為什么會出現精度丟失?
答:這跟浮點數在計算機內部(用二進制存儲)的表示方法有關。
JS 采用 IEEE 754 標准的 64 位雙精度浮點數表示法,這個標准是20世紀80年代以來最廣泛使用的浮點數運算標准,為許多CPU與浮點運算器所采用,也被很多語言如 java、python 采用。
這個標准,會讓大部分的十進制小數都不能用二進制浮點數來精確表示(比如轉成二進制就會變成無限小數)。所以一般情況下,你輸入的十進制小數僅由實際存儲在計算機中的近似的二進制浮點數表示。
然而,許多語言在處理的時候,在一定誤差范圍內(通常極小)會將結果修正為正確的目標數字,而不是像 JS 一樣將存在誤差的真實結果轉換成最接近的小數輸出。
具體原理可以看《浮點數的二進制表示 —— 阮一峰》,這里不贅述了。
(3)問:怎么避免精度丟失?
方法一:中途變成整數來計算
比如我們要計算 0.1 + 0.2,就先把數字全部乘以 10 使之變成整數,再相加,最后把結果除以 10。
因為整數是不會出現精度丟失的問題。(況且整數根本就沒有精度)
其實很多第三方的庫,原理也是用的這個。
方法二:使用第三方庫
- Math.js
- decimal.js
- big.js
- bignumber.js
方法三:使用 toFixed() 函數(推薦)
console.log(parseFloat((0.3 + 0.1).toFixed(1))) // 0.4
注意:toFixed() 最好跟 parseFloat() 搭配使用。因為 toFixed 返回的是字符串。
問:toFixed() 為什么要返回字符串,而不是小數?【重點】
答:因為 JavaScript 的數據類型,關於數字的只有 number 類型(不像 C 語言 or 數據庫等還分 int、float、double),而對於 number 類型來說, 會忽略前置0和小數點后的后置0(比如 001 是 1; 1.1000 是 1.1)。
在下面還會繼續介紹
toFixed()的關於舍入的特性。
三、JS 與 近似計算方法
在上面提到的:
- 精度計算
- 精度丟失
都會有可能讓精度發生變化(即小數點后位數變化)。如果我們需要統一精度,那就需要用到近似(計算)方法。
1、四舍五入
(1)規則
四舍五入是最常見的近似計算方法,具體規則顧名思義,不贅述了。
(2)Math.round()
給定數字的值四舍五入到最接近的整數。
Math.Round(2.4) // 2
Math.Round(2.5) // 3
(3)_.round() —— lodash
給定數字的值四舍五入到最接近的數(可以是小數)。
lodash 的這個方法,我看了源碼,底層也是調用的 Math.round(),只是加了一些額外功能,比如第二個參數,可以指定四舍五入的精度。
const _ = require('lodash');
_.round(1.04, 1) //1
_.round(1.05, 1) //1.1
(4)四舍五入真的公平嗎?【重點】
因為自己很小的時候就在學校學到了四舍五入,一直想當然的認為四舍五入是公平的,等到現在細想的時候,才發現,真的不公平。
例如,想象一個場景,你的余額寶,每天會自動結算利息,但是可能(按照利息規則)算出來的值的小數有很多位,假設支付寶只支持到角,那么支付寶系統幫你記賬的時候,肯定會給你近似計算,如果他用的是四舍五入的方法:
const _ = require('lodash');
console.log(_.round(1.01, 1)) //1 (我虧了0.01)
console.log(_.round(1.02, 1)) //1 (我虧了0.02)
console.log(_.round(1.03, 1)) //1 (我虧了0.03)
console.log(_.round(1.04, 1)) //1 (我虧了0.04)
console.log(_.round(1.05, 1)) //1.1 (我賺了了0.05)
console.log(_.round(1.06, 1)) //1.1 (我賺了0.04)
console.log(_.round(1.07, 1)) //1.1 (我賺了0.03)
console.log(_.round(1.08, 1)) //1.1 (我賺了0.02)
console.log(_.round(1.09, 1)) //1.1 (我賺了0.01)
首先,1 塊錢整和 2 塊錢整可以不用考慮,其次,如果假設 1.01 到 1.09 這 9 個數出現的概率一致。那么最后支付寶肯定要虧本,因為 1.05 划分到 1.1 是不公平的。
也可以畫一個數軸來體現:

那么如何做到更公平的近似計算呢?可以用下面介紹的銀行家舍入。
2、銀行家舍入
國際通行的是 銀行家舍入(Banker's rounding)算法 。
是 IEEE 規定的舍入標准。因此所有符合 IEEE 標准的語言都應該是采用這一規則的。
(1)規則
銀行家舍入又稱四舍六入五取偶(又稱四舍六入五留雙)法。
所以規則就是:四舍六入五考慮,五后非空就進一,五后為空看奇偶,五前為偶應舍去,五前為奇要進一。
關鍵就是“五后為空看奇偶”,因為如果是舍入位是5,無論是舍還是入都不公平,那就交給它前一位的奇偶性來判斷,因為奇偶性分布概率是公平的。
當然只能說銀行家舍入算法比四舍五入算法更科學,而不能說它就是絕對正確,而四舍五入就是錯誤的,因為這些結果都是基於統計數據產生的,前提就是這些數據搖符合隨機性分布的要求。
(2)使用
目前 JS 上原生不支持,如果想使用:
- 1、自己實現
- 2、使用第三方 npm 包,如 bankers-rounding
3、toFixed
toFixed() 部分符合銀行家舍入的規則。
(1)四舍六入
符合
(2)五后非空就進一
符合
(3)五后為空看奇偶,五前為偶應舍去,五前為奇要進一
部分符合
// //toFixed結果 //銀行家舍入結果
console.log(1.05.toFixed(1)) //1.1(+0.05) 1.0(-0.05)
console.log(1.15.toFixed(1)) //1.1(-0.05) 1.2(+0.05)
console.log(1.25.toFixed(1)) //1.3(+0.05) 1.2(-0.05)
console.log(1.35.toFixed(1)) //1.4(+0.05) 1.4(+0.05)
console.log(1.45.toFixed(1)) //1.4(-0.05) 1.4(-0.05)
console.log(1.55.toFixed(1)) //1.6(+0.05) 1.6(+0.05)
console.log(1.65.toFixed(1)) //1.6(-0.05) 1.6(-0.05)
console.log(1.75.toFixed(1)) //1.8(+0.05) 1.8(+0.05)
console.log(1.85.toFixed(1)) //1.9(+0.05) 1.8(-0.05)
console.log(1.95.toFixed(1)) //1.9(-0.05) 2.0(+0.05)
// //總計(+0.1) //總計(0)
可以看出 toFixed 肯定是不遵守四舍五入的,但是也跟銀行家舍入算法有出入。(具體為什么是這樣的計算方法,鄙人並不是弄清楚,待寫)
4、其他 近似計算 函數
- Math.ceil():向上舍入(取整)
- Math.floor():向下舍入(取整)
- 等等……
