JavaScript 舍入误差(在金融应用程序中)

原文: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.jsdecimal.jsdinero.jscurrency.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四舍五入为 00.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 的银行家舍入方法解决了问题。重要的经验是,我们应深入了解应用程序的关键部分并对其进行全面测试。此外,在向客户推出关键功能后,应密切监控并快速响应问题。

例如,我们不得不追溯修正数据库中的一些发票,因为舍入问题导致错误。虽然只涉及几美分,但为了客户满意,我们花费了大量时间进行业务沟通和调整。


在推出该功能后,客户反响良好。经过努力,我们成功地创建了一个稳定的系统,已经产生数百张发票(及其取消),效果如预期。我非常自豪,从零开始构建了这个系统。我希望这篇文章能帮助你避免我所犯的错误,并成功搭建出一个稳定的金融基础设施。

1_1025715938_2007_94_3_919650622_3897f447572b21673da004dd8d953ad7.png

前端妙妙屋 - 分享前端实战经验,点亮你的编程灯塔

请添加图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

泯泷

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值