译:JavaScript 舍入错误

原文:https://www.robinwieruch.de/javascript-rounding-errors/
作者:Robin Wieruch
译者:ChatGPT 4 Turbo

译者注:1)0.1 + 0.2 不等于 0.3 大家都知道,2)但是有些问题是比较隐蔽的,只在某些部分值上有问题,比如 Math.round(1.255*100)/100 并不等于 1.26,3)虽然可以尽量用整数做计划,但涉及税收、折扣时,又无法避免小数,4)可以用 big.js、currency.js 等库做小数的精确运算,但需注意 currency.js 只支持四舍五入,不支持银行家舍入法,这在做负数的四舍五入时会有问题,换 big.js 可解,5)当然,最好的方式是不在客户端做运算,全部交给服务端处理。

四舍五入误差是在 JavaScript 和其他编程语言中处理浮点数时常见的问题。你可能在尝试将两个十进制数如 0.1 + 0.2 相加并期望结果为 0.3 时遇到过这个问题。然而,结果不是 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);
// 结果: 1.25
// 预期: 1.26

这种意外的行为并不总是一致的,因此很容易被忽视。例如,下面的代码片段就如预期工作:

console.log(Math.round(2.255 * 100) / 100);
// 结果: 2.26
// 预期: 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

这个问题很容易被忽视,并且如果你没有意识到它,可能会在你的应用程序中引入 bug。从这里开始,还有绕过这个问题的方法。例如,在舍入之前给数字加上一个小值(如 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);
// 结果: 10.07
// 预期: 10.08

处理发票系统时,我是通过一些辛苦的方式学到了这一切。由于这一切并不总是可预测的,你可能认为你已经解决了所有问题,但然后你又遇到了另一个问题。在我创建发票的情况下,你想确保数字是正确的,因为发票通常是不可变的,你不希望在它们创建后进行更改。尤其是当你已经有客户时。

避免舍入误差的 JavaScript 库

如果这些舍入误差对您的应用程序至关重要,您可能会想要使用一个库来帮您处理舍入问题。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但当您镜像数字时,您得到的是 -10.07 而不是 -10.08

console.log(currency(10.075).value);
// 10.08
console.log(currency(-10.075).value);
// -10.07

一个挑剔的开发者可能会说问题不在于舍入,而在于数字的镜像。这是我们在一个已经很复杂的系统中必须权衡利弊的一个架构决策。决定是镜像数字,因为我们能够复用复杂计算的代码(涉及(部分)折扣(在一部分职位上)、税收、取消、存款发票……)。我们也能够使用数据库模型来处理已取消的发票,这是一个很大的优势。

事实证明,currency.js 使用四舍五入的方法进行舍入。在撰写本文时,这是唯一支持的舍入类型。这意味着数字被舍入到最接近的整数,如果数字正好在两个整数之间,则向上舍入。这就是取消的发票金额为 -10.07 而不是 -10.08 的原因。

由于取消发生的频率不如发票那么高,这个问题并不会立刻显现出来。因此,在你即将向客户推出开票系统的场景中,这是一个关键性的bug。如前所述,发票应该是不可变的,一旦它们被创建并发送给客户,就不应该被更改。至少在我居住的德国以及我服务的客户群体中,情况确实如此。

在处理金融应用程序时,你必须意识到不同类型的舍入方法最常见的舍入类型包括:

  • 四舍五入:这是最常见的舍入类型,数字被舍入到最接近的整数。如果数字正好在两个整数之间,则向上舍入。
  • 五舍六入:这与四舍五入类似,但如果数字正好在两个整数之间,则向下舍入。
  • 向零舍入:这种类型的舍入将数字舍入为零。正数向下舍入,负数向上舍入。
  • 远离零舍入:这种类型的舍入将数字舍入远离零。正数向上舍入,负数向下舍入。
  • 四舍六入取偶:这种类型的舍入将数字舍入到最近的偶数整数。如果数字正好在两个整数之间,它将被舍入到最近的偶数整数。

后一种也被称为银行家舍入法,在金融应用程序中用于最小化舍入误差。这是因为它向最接近的_偶数_整数舍入,这意味着舍入是无偏的。例如,-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,我能够采用银行家舍入法,问题也随之得到了解决。我猜这里学到的教训是,你应该始终意识到你的应用中的关键部分,并进行彻底的测试。此外,在向客户推出关键功能后,你应该密切监控它,并准备好在问题出现时尽快修复。

例如,我们不得不在数据库中对几张发票进行追溯修正,因为由于四舍五入的问题,它们是错误的。这对我们来说是个大问题,因为我们不得不通知一个双边市场的客户,他们的发票有误,实际支付的金额可能比他们想的多或少。尽管只是几分钱的差别,但这仍然是商业开发中投入了大量时间去吸引客户的,现在我们不得不告诉他们我们犯了一个错误。


该功能已经推出,客户们也很满意。投入了大量的汗水和泪水才把这个功能做到生产环境中,但这一切都是值得的。从那以后,我们创建了数百份发票(以及取消发票),一切都按预期工作。应用中的第一个客户的收入刚刚达到了 100,000 美元,我为自己能从零开始构建这个系统感到骄傲。我希望这篇文章能帮助你避免我犯过的同样错误,并且你能自己构建起关键的金融基础设施。