原文:https://www.robinwieruch.de/javascript-rounding-errors/
原标题:JavaScript Rounding Errors (in Financial Applications)
作者:ROBIN WIERUCH
在 JavaScript 和其他编程语言中,处理浮点数时常常会遇到舍入错误的困扰。一个典型的例子是在计算 0.1 + 0.2
时,预期的结果应该是 0.3
,但实际结果却是 0.30000000000000004
。
console.log(0.1 + 0.2); // 0.30000000000000004
在财务应用程序中,你不想处理这些问题,也不想将这些数字放入数据库中。但为什么我有资格写这个问题呢?
去年,我开始为一家初创公司工作,我们的应用程序必须包含一个集成的发票系统。由于我使用 Next.js 构建了整个应用程序,系统是用 TypeScript 开发的。因此,我必须处理大量数学运算和舍入错误。在此,我想与大家分享我的经验以及我是如何解决这些问题的。
JavaScript 中的四舍五入
JavaScript 中有多种四舍五入方法。例如,你可以使用 JS 原生的 Math.round()
、Math.floor()
或 Math.ceil()
函数来处理取整。 Math.round()
函数将数字四舍五入为最接近的整数,这在保存货币值(例如,以整数形式表示的分)时通常需要。
console.log(Math.round(0.1 + 0.2)); // 0
如果要保留小数位,可以将数字乘以一个因子,四舍五入,再除以同一因子。例如,要将数字四舍五入到小数点后一位,你可以将该数字乘以 10
,四舍五入,然后除以 10
。
console.log(Math.round((0.1 + 0.2) * 10) / 10); // 0.3
从事金融和软件开发的人都知道,货币值应该在数据库中以整数(例如美分)存储,而不是浮点数(美元),因为浮点数的二进制表示法可能会引发舍入错误。因此,应始终将货币值存储为整数,仅在需要显示时将其转换为浮点数。然而,在处理税收、折扣等功能时,你将不可避免地遇到小数。
不幸的是,以上四舍五入方法并不是万无一失的。例如,当我们试图将数字四舍五入到小数点后两位时,JavaScript 中的浮点数有时会出错:
console.log(Math.round(1.255 * 100) / 100);
// result: 1.25
// expected: 1.26
这种意外行为并不总是一致,容易被忽视。例如,以下代码片段能够按预期工作:
console.log(Math.round(2.255 * 100) / 100);
// result: 2.26
// expected: 2.26
JavaScript 与许多其他语言一样,使用二进制浮点算法(具体来说,IEEE 754 标准)来表示数字。由于二进制性质,这种格式无法准确表示某些十进制数,从而导致微小的精度误差。
let intermediate = 1.255 * 100;
console.log(intermediate); // 125.49999999999999
console.log(Math.round(intermediate)); // 125
console.log(Math.round(intermediate) / 100); // 1.25
另一方面,对于其他数字,它又能够按预期工作:
let intermediate = 2.255 * 100;
console.log(intermediate); // 225.5
这种问题容易被忽视,如果你没有意识到,可能会导致应用程序中的错误。为了解决这些问题,可以在舍入前向数字添加一个非常小的值(例如 Number.EPSILON
),以确保舍入正确完成。这是一种在 JavaScript 中避免大多数舍入错误的常用技术:
console.log(Math.round((1.255 + Number.EPSILON) * 100) / 100);
// 1.26
但这并非是防弹的。例如,以下示例中,结果仍然不符合预期:
console.log(Math.round((10.075 + Number.EPSILON) * 100) / 100);
// result: 10.07
// expected: 10.08
在开发发票系统时,我深知这一点。由于舍入误差并不总是可以预见,因此即使你认为解决了所有问题,仍可能会遇到新的问题。对于发票的情况,确保数字准确无误至关重要,因为发票通常是不可更改的,尤其是在已经发送给客户之后。
避免舍入错误的 JS 库
如果这些舍入错误对你的应用程序至关重要,可以考虑使用专门的库来处理舍入问题。诸如 big.js
、decimal.js
、dinero.js
和 currency.js
这样的库提供精确的算术运算。前者是通用库,而后者专为处理货币值设计。因此,我选择 currency.js
用于我的发票系统:
console.log(currency(0.1).add(0.2).value);
// 0.3
console.log(currency(1.255).value);
// 1.26
console.log(currency(10.075).value);
// 10.08
这些库专为处理十进制数而设计,并在处理浮点数时提供更一致的行为,特别适用于金融应用。因此,我在发票系统中使用了 currency.js
并认为问题已解决。然而,当该功能即将向客户发布时,我发现问题依然存在。
舍入类型
发票系统需具有取消发票的功能。当你取消发票时,需冲销原发票并创建负值的已取消发票。例如,如果一张发票的总和为 10.075
,那么取消发票时的总和应为 -10.075
。但在镜像这些数字时,结果并不一致:
console.log(currency(10.075).value);
// 10.08
console.log(currency(-10.075).value);
// -10.07
挑剔的开发人员可能会指出,这是舍入而非镜像的问题。根据此架构决策,为避免过于复杂的系统决定了镜像这些数字,并重复使用计算代码。这种方法允许我们简化数据库模型,但引发了新的问题。
currency.js
使用的是半上舍入方法,而这是目前唯一支持的舍入类型。这意味着数字四舍五入到最接近的整数,如果正好在两个整数之间,则舍入。由于这种方法,导致取消的发票的总和为 -10.07
而非 -10.08
。
在向客户推出发票系统前,这个问题显而易见了。发票一旦创建并发送给客户,就不应更改,至少在我所在的德国以及我服务的客户中是这样。
在使用金融应用时,了解不同类型的舍入很重要。以下是一些常见的舍入类型:
- 四舍五入:数字四舍五入到最接近的整数。如果数字正好在两个整数之间则舍入。
- 五舍六入:与四舍五入类似,但如果数字正好在两个整数之间,则舍去。
- 零舍入:数字向零舍入。正数向下舍入,负数向上舍入。
- 远离零舍入:数字远离零舍入。正数向上舍入,负数向下舍入。
- 偶数舍入:也称为银行家舍入,数字四舍五入到最接近的偶数整数。这种方法用于金融应用以最大限度地减少舍入误差。例如,
-0.5
四舍五入为0
,0.5
也是0
。
由于 currency.js
仅支持半上舍入,并不符合我们的需求,我改用了支持银行家舍入的 big.js
:
import Big from "big.js";
Big.RM = Big.roundHalfEven;
console.log(Big(10.075).round(2)); // 10.08
console.log(Big(-10.075).round(2)); // -10.08
使用 big.js
的银行家舍入方法解决了问题。重要的经验是,我们应深入了解应用程序的关键部分并对其进行全面测试。此外,在向客户推出关键功能后,应密切监控并快速响应问题。
例如,我们不得不追溯修正数据库中的一些发票,因为舍入问题导致错误。虽然只涉及几美分,但为了客户满意,我们花费了大量时间进行业务沟通和调整。
在推出该功能后,客户反响良好。经过努力,我们成功地创建了一个稳定的系统,已经产生数百张发票(及其取消),效果如预期。我非常自豪,从零开始构建了这个系统。我希望这篇文章能帮助你避免我所犯的错误,并成功搭建出一个稳定的金融基础设施。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。