上回試著克服 JavaScript 四捨五入浮點誤差,小看了浮點數誤差的千變萬化,只手動測完幾組數字就妄想過關,不意外地,被抓出兩個問題:

  1. 原先用整數跟小數總計 16 位判斷出現 999X 或 0000X 近似值誤差,但 JavaScript Number 規格位數上限是 17 位(A Number only keeps about 17 decimal places of precision),發生近似值誤差時的位數可能是 16 位或 17 位,例如:
    1.1 + 3.2 = 4.300000000000001 不含小數點共 16 位
    4.17 * 3.251 = 13.556669999999999 不含小數點共 17 位
  2. 即使簡單的 * 100 也可能出現浮點數誤差,例如:1.005 * 100 = 100.49999999999999,在計算 Math.round(n * 100) / 100 取小數兩位時完全沒考慮到這點

總之,原來的版本遇到某些數字會出錯,必須改良,但這回我改變做法,先寫好檢測程序再來改程式,函式要經過檢驗才能出廠。我想到的做法是用亂數產生十萬組測試數字(依實際應用情境,四捨五入小數位數抓 0 到 4 位),用 C# decimal 算出標準答案,與 JavaScript 版本比對,以確保函式不存在已知錯誤。改良前先用舊版本測試,驗證它能抓出問題:

<%@Page Language="C#"%>
<script runat="server">
class TestCase
{
	public decimal a { get; set; }
	public decimal b { get; set; }
	public int n { get; set; }
	public decimal ans => 
		(decimal)Math.Round(a + b, n, MidpointRounding.AwayFromZero);
}
string TestCasesJson() 
{
	var cases = new List<TestCase>();
	var rnd = new Random(9527);
	for (int i = 0; i < 100000; i++) 
	{
		cases.Add(new TestCase 
		{
			a = (decimal)Math.Round(
					rnd.NextDouble() * 100, rnd.Next(5), MidpointRounding.AwayFromZero),
			b = (decimal)Math.Round(
					rnd.NextDouble() * 100, rnd.Next(5), MidpointRounding.AwayFromZero),
			n = rnd.Next(5)
		});
	}
	return new System.Web.Script.Serialization.JavaScriptSerializer() {
		MaxJsonLength = int.MaxValue
	}.Serialize(cases);
}
</script>

<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8">
</head>
<body>
<div id=status></div>
<div id=msg>
</div>
<script>
function safeRound(v, n) {
    // 含小數達16位數時,小數部分四捨五入減少一位
    // 注意:若該精確度極限數字非浮點運算產生時會被喪失一位精確度
    let p = v.toString().split('.');
    if (p.length == 2) {
      let intLen = p[0].length;
      let decLen = p[1].length;
      if (intLen + decLen >= 16) {
        let tt = Math.pow(10, decLen - 1);
        v = Math.round(v * tt) / tt;
      }
    }
    let t = Math.pow(10, n);
    return Math.round(v * t) / t;
}
var testCases = <%=TestCasesJson()%>;
var count = 0;
testCases.forEach(function(test, i) {
	var r = safeRound(test.a + test.b, test.n);
	if (r != test.ans) {
		document.getElementById('msg').innerHTML += 
			'<li>' + (++count) + '. TEST FAILED: Round(' + test.a + ' + ' + test.b + ', ' + test.n + ') = ' 
				+ r + ' (' + test.ans + ' expected)</li>';
	}
	document.getElementById('status').innerText = 
		count + '/' + (i + 1) + ' (' + (count / (i + 1) * 100) + '%)';
});
</script>
<body>
</html>

使用固定亂數種子產生十萬筆隨機數字,舊函式實測共出錯 57 筆,錯誤率 0.057%,相當於萬分之 5.7。(另外我也試了不指定亂數種子,new Random(9527) 改為 new Random() 又測了數十次,十萬筆錯誤數落在 40 到 68 之間)

借用讀者 citypig 分享的兩則技巧: float_number % 1 !== 0 測試是否含小數、toPrecision(15) 直接抓 15 位精準度,我將 safeRound 函式改寫如下:

function safeRound(v, n) {
    if (v % 1 !== 0) {
        v = parseFloat(v.toPrecision(15));
    }
    var t = Math.pow(10, n);
    var nv = v * t;
    if (nv % 1 !== 0) {
	    nv = parseFloat(nv.toPrecision(15));
	}
    return Math.round(nv) / t;
}

不指定亂數種子做了二十次十萬筆測試,錯誤數均為零:

有出廠檢驗把關,希望這版 safeRound() 能可靠一些,大家如發現問題歡迎再回饋給我。

Improved JavaScript float number rounding function.


Comments

# by Dennis

Are you really need to round it? for just format it well? https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat

# by C.I.Hsieh

建議使用big.js、bignumber.js或decimal.js等這類高精度運算的函式庫,不然會有補不完的洞。

# by Jeffrey

to Dennis, 有些情境需要計算四捨五入後的數字做為其他函式或API的輸入參數,未必都是格式化需求。 to C.I.Hsieh,感謝經驗分享,第三方數字型別要用自訂 Method 取代較直覺的 +-*/ 運算子,方便度不佳,是種取捨。

# by citypig

JavaScript 的浮點數是我自己也一知半解的東西。 大概也只有你會認真的做出幾十萬筆的驗證,真了不起!

Post a comment