使用BigDecimal处理货币的一个现实例子,比使用double更加严格。

45

我们知道,在货币方面使用double是容易出错且不推荐的。然而,我还没有看到一个实际的例子,BigDecimal能够工作,而double失败了,并且不能通过一些舍入方法简单地修复。


请注意,这里的问题很简单。
double total = 0.0;
for (int i = 0; i < 10; i++) total += 0.1;
for (int i = 0; i < 10; i++) total -= 0.1;
assertTrue(total == 0.0);

不要计算,因为它们可以通过四舍五入轻松解决(在此示例中,零到十六位小数均可)。


计算涉及对大数值进行求和可能需要一些中间舍入,但是考虑到流通中的货币总额USD 1e12,Java double(即标准IEEE双精度)的15个小数位仍足以处理分。

通常涉及除法的计算即使使用 BigDecimal 也是不精确的。我可以构建一个计算,它不能使用 double 进行操作,但使用小数点后 100 位的 BigDecimal 可以进行操作,但这不是你在现实中会遇到的情况。


我并不否认这样一个现实的例子不存在,只是我还没有看到过。
我也完全同意,使用double更容易出错。
示例
我正在寻找像以下方法一样的方法(基于Roland Illig的答案)
/** 
  * Given an input which has three decimal places,
  * round it to two decimal places using HALF_EVEN.
*/
BigDecimal roundToTwoPlaces(BigDecimal n) {
    // To make sure, that the input has three decimal places.
    checkArgument(n.scale() == 3);
    return n.round(new MathContext(2, RoundingMode.HALF_EVEN));
}

连同类似的测试一起

public void testRoundToTwoPlaces() {
    final BigDecimal n = new BigDecimal("0.615");
    final BigDecimal expected = new BigDecimal("0.62");
    final BigDecimal actual = roundToTwoPlaces(n);
    Assert.assertEquals(expected, actual);
}

当使用double进行天真重写时,测试可能会失败(对于给定的输入数据不会失败,但对于其他输入数据会失败)。然而,可以正确地完成此操作:
static double roundToTwoPlaces(double n) {
    final long m = Math.round(1000.0 * n);
    final double x = 0.1 * m;
    final long r = (long) Math.rint(x);
    return r / 100.0;
}

这段代码丑陋且容易出错(而且可能可以简化),但是它可以很容易地封装到某个地方。这就是为什么我正在寻找更多答案的原因。


2
我的理解是,一些(大多数?)国家在法律上要求会计和财务相关的数学以某种十进制为基础的形式进行,遵循一些特定的舍入规则并指定小数点后使用的位数。 - rcgldr
1
那么你认为“现实”的定义是什么?你提到了大约1万亿的货币流通总量,但显然涉及“货币”的计算需要处理非物质数量,这些数量通常远远超过纸币的流通量。例如,世界GDP超过1e14,我肯定可以想象其他具有更大价值的货币数字。因此,示例可以使用像1e16这样的大数字吗? - BeeOnRope
特别是“受到舍入误差影响的表达式不计入其中”这一要求很令人困惑:你可以将浮点结果中的任何不准确性都归因于“舍入误差”。也许举一个您正在寻找的方法类型的例子会有所帮助(即使它使用double)。 - BeeOnRope
1
@maaartinus - 尽管我认为使用“现实”这个词留下了很大的余地,使其在技术上未定义,但你已经让我清楚地尝试回答了。 - BeeOnRope
1
@GhostCat,我刚刚接受了一个答案,但实际上我并不满意。我的问题比我想象的要复杂。 - maaartinus
显示剩余17条评论
8个回答

30

在处理货币计算时,我可以看到“双倍精度”存在四种基本的坑人方法。

尾数太小

尾数只有 ~15 个十进制数字的精度,在处理比这更大金额时会得到错误结果。如果您要跟踪美分,则在 1013(一万亿)美元之前就会出现问题。

虽然这是一个很大的数字,但也不是“那么大”。美国的 18 万亿美元的 GDP 超过了它,因此在处理国家甚至公司规模的金额时可能会轻易出现错误答案。

此外,在计算过程中,有很多小额金额可能会超过此阈值。您可能正在进行增长预测或多年的分析,从而得出一个大的最终价值。您可能正在进行“假设”情景分析,其中会检查各种可能的参数,某些参数的组合可能会导致非常大的值。您可能正在遵守允许使用美分的金融规则,这可能会将您的范围再减少两个数量级或更多,使您与美元中普通人的财富大致相当。
最后,让我们不要以美国为中心看待事物。其他货币呢?一美元大约相当于13,000印尼盾,因此在该货币中,您需要跟踪货币金额的另外两个数量级(假设没有“分”!)。您几乎可以接近普通人感兴趣的金额了。

这里是一个例子,从10亿开始以5%的增长率进行的增长预测计算出现了错误:

method   year                         amount           delta
double      0             $ 1,000,000,000.00
Decimal     0             $ 1,000,000,000.00  (0.0000000000)
double     10             $ 1,628,894,626.78
Decimal    10             $ 1,628,894,626.78  (0.0000004768)
double     20             $ 2,653,297,705.14
Decimal    20             $ 2,653,297,705.14  (0.0000023842)
double     30             $ 4,321,942,375.15
Decimal    30             $ 4,321,942,375.15  (0.0000057220)
double     40             $ 7,039,988,712.12
Decimal    40             $ 7,039,988,712.12  (0.0000123978)
double     50            $ 11,467,399,785.75
Decimal    50            $ 11,467,399,785.75  (0.0000247955)
double     60            $ 18,679,185,894.12
Decimal    60            $ 18,679,185,894.12  (0.0000534058)
double     70            $ 30,426,425,535.51
Decimal    70            $ 30,426,425,535.51  (0.0000915527)
double     80            $ 49,561,441,066.84
Decimal    80            $ 49,561,441,066.84  (0.0001678467)
double     90            $ 80,730,365,049.13
Decimal    90            $ 80,730,365,049.13  (0.0003051758)
double    100           $ 131,501,257,846.30
Decimal   100           $ 131,501,257,846.30  (0.0005645752)
double    110           $ 214,201,692,320.32
Decimal   110           $ 214,201,692,320.32  (0.0010375977)
double    120           $ 348,911,985,667.20
Decimal   120           $ 348,911,985,667.20  (0.0017700195)
double    130           $ 568,340,858,671.56
Decimal   130           $ 568,340,858,671.55  (0.0030517578)
double    140           $ 925,767,370,868.17
Decimal   140           $ 925,767,370,868.17  (0.0053710938)
double    150         $ 1,507,977,496,053.05
Decimal   150         $ 1,507,977,496,053.04  (0.0097656250)
double    160         $ 2,456,336,440,622.11
Decimal   160         $ 2,456,336,440,622.10  (0.0166015625)
double    170         $ 4,001,113,229,686.99
Decimal   170         $ 4,001,113,229,686.96  (0.0288085938)
double    180         $ 6,517,391,840,965.27
Decimal   180         $ 6,517,391,840,965.22  (0.0498046875)
double    190        $ 10,616,144,550,351.47
Decimal   190        $ 10,616,144,550,351.38  (0.0859375000)

差值(doubleBigDecimal之间的差异)在160年时首次超过1美分,约为2万亿(也许从现在起160年并不算太多),当然这个差距只会越来越大。

当然,53位尾数意味着这种计算的相对误差可能非常小(希望你不会因为2万亿中的1美分而失去工作)。实际上,相对误差在大部分示例中基本保持稳定。您当然可以将其组织起来,例如通过减去两个具有尾数精度损失的数字,从而导致任意大的误差(读者可自行练习)。

更改语义

所以你认为自己很聪明,想出了一个舍入方案,可以使用double并已在本地JVM上进行了详尽的测试。那就部署吧。明天、下周或者最糟糕的时间,结果会发生变化,你的技巧就会失效。

与几乎所有其他基本语言表达式以及整数或BigDecimal算术不同,由于strictfp特性,许多浮点表达式的结果默认情况下没有单一的标准定义值。平台可以自行决定使用更高精度的中间值,在不同的硬件、JVM版本等上可能会导致不同的结果。对于相同的输入,当方法从解释执行切换到JIT编译时,结果甚至可能在运行时发生变化

如果您在Java 1.2之前编写代码,当Java 1.2突然引入现在默认的变量FP行为时,您可能会感到非常恼火。您可能会尝试在所有地方都使用strictfp,并希望您不会遇到任何相关错误的大量问题 - 但在某些平台上,您将放弃双倍带给您的大部分性能。

可以说,JVM规范未来可能会再次更改以适应FP硬件的进一步变化,或者JVM实现者可能会利用默认的非严格FP行为给他们带来的优势来做一些巧妙的事情。

不精确的表示

正如Roland在他的回答中指出的那样,double的一个关键问题是它没有某些非整数值的精确表示。虽然单个非精确值(例如0.1)在某些情况下通常可以“往返”(例如Double.toString(0.1).equals("0.1")),但只要对这些不精确的值进行数学运算,误差就会累积,这可能是无法恢复的。

特别是,如果您“接近”一个舍入点,例如~1.005,当真实值为1.0050000001...时,您可能会得到1.00499999...的值,反之亦然。由于误差方向相反,因此没有舍入魔法可以解决这个问题。无法确定1.004999999...的值是否应该上调。您的roundToTwoPlaces()方法(一种双重舍入)之所以有效,是因为它处理了1.0049999应该上调的情况,但它永远无法跨越边界,例如,如果累积误差导致1.0050000000001变成1.00499999999999,则无法修复它。
您不需要大或小的数字来达到这个目的。您只需要进行一些数学计算,并使结果接近边界即可。您进行的数学计算越多,可能偏离真实结果的范围就越大,同时也更有可能跨越边界。

根据请求,这里有一个搜索测试,它进行了简单的计算:amount * tax并将其四舍五入到2个小数位(即美元和美分)。其中有几种舍入方法,当前使用的roundToTwoPlacesB是您的升级版1(通过增加第一轮舍入中n的乘数,使其更加敏感——原始版本在微不足道的输入上立即失败)。

测试会输出它找到的失败,并且它们会成批出现。例如,前几次失败:

Failed for 1234.57 * 0.5000 = 617.28 vs 617.29
Raw result : 617.2850000000000000000000, Double.toString(): 617.29
Failed for 1234.61 * 0.5000 = 617.30 vs 617.31
Raw result : 617.3050000000000000000000, Double.toString(): 617.31
Failed for 1234.65 * 0.5000 = 617.32 vs 617.33
Raw result : 617.3250000000000000000000, Double.toString(): 617.33
Failed for 1234.69 * 0.5000 = 617.34 vs 617.35
Raw result : 617.3450000000000000000000, Double.toString(): 617.35

请注意,“原始结果”(即精确的未舍入结果)总是接近边界。您的舍入方法会在高和低两侧出错。你不能通用地修复它。

不精确计算

java.lang.Math 的几个方法并不要求正确舍入的结果,而是允许误差达到2.5 ulp。当然,你可能不会经常在货币计算中使用双曲函数,但是诸如exp()pow()等函数经常在货币计算中出现,而这些函数只有1 ulp的精度。所以当返回这个数字时,它已经是“错误”的。

这与“不精确表示”问题相互作用,因为这种类型的错误比普通数学运算引起的错误更严重,后者至少从可表示的域中选择最佳值。这意味着当您使用这些方法时,可能会有更多的舍入边界穿越事件发生。


1
尾数太小 - 除了模拟类似于BigDecimal的东西之外,我无能为力。 语义改变 - 这可以通过两次舍入来解决:首先将精确结果舍入到可以具有的小数位数(例如,1.23 * 45.678保留五位小数),然后将结果舍入到所需的精度。 不精确的表示 - 也可以通过双重舍入来解决(如果您想要,我会发布详细信息),因为它会消除任何多余的精度。 不精确的计算 - 除了pow之外,它们在BigDecimal中也缺失。而且,BigDecimal.pow也是不精确的(> 1 ulp)。 - maaartinus
尽管如此,我最喜欢你的答案...虽然我更喜欢一个我可以解决或不解决的单一问题。我会尝试需要双重舍入的部分。顺便说一下,后来我发现round是无用的,因为它向+INF舍入,rint才是正确的方法。我猜,这也可能需要一些基准测试,因为双重舍入可能有点昂贵。 - maaartinus
@maartinus - 是的,主要的“可解决”(或不可解决)问题是不精确的表示问题。这是我回复的关键点(尾数位是有趣的,但正如你所指出的,你不能做太多事情)。我很想看看你为双重舍入想出了什么。请注意,从某种角度来看,你的工作比我的更难 - 你必须想出一个适用于所有值的函数,而不仅仅是一个案例。我只需要一个反例。另一方面,有时候反例很难找到! - BeeOnRope
你可以从某个表达式中得到它。在我的链接测试中,它是一个简单的乘法。我不同意你的逻辑。你并不一定知道这一点。此外,你怎么知道需要舍入多少位小数?输入可以具有任意复杂性,并且是其他计算的结果。我不是在谈论加上整数便士或任何东西,我是在谈论从即使是简单的事情如复利或任何涉及除法等等中获得的基本任意值。请参见我的链接测试。 - BeeOnRope
让我们在聊天中继续这个讨论 - maaartinus
显示剩余2条评论

21

当你将double price = 0.615保留两位小数进行四舍五入时,会得到0.61(向下取整),但你可能期望得到0.62(向上取整,因为其中包含5)。

这是因为双精度浮点数0.615实际上是0.6149999999999999911182158029987476766109466552734375。


有趣。例如,可以通过打五折从1.23获得0.615。但是,使用“round(1.23*.5 * 1e2) / 1e2”,我得到了0.62,所以我的附加说明不起作用。+++无论如何,我相信,1.它可以被解决,但2.舍入问题可以得到解决,3.可能会变得非常复杂。 - maaartinus
只需循环遍历0.0000到10.0000之间的所有数字,以查看它们是否都表现一致。此外,当您的方法未标记为strictfp时,在代码被优化后,您可能会得到不同的结果。 - Roland Illig
21.15 的10%应为2.12,但 Math.round((21.15 * .1) * 1e2)/1e2 得出2.11,所以请确保存在这种情况。无论如何,当考虑到这些情况仅在将某个因子乘以某个金额后才会发生,并且您通常也知道这种乘法的精确结果最多有几个小数位时,可以适当处理这些情况。 - Markus Benko
我实际上获得了0.62。这可能只是偶然事件,但我敢打赌,对于任何这样的输入(假设输入范围合理),我都可以获得正确舍入的结果。 - maaartinus
1
@maartinus 你测量的是其他的东西。当你乘以100时,尾数的位模式会改变。一般的问题仍然存在。 - Roland Illig
@RolandIllig 这不是其他东西,因为没有像 roundDoubleToTwoDecimalPlaces 这样的内置方法,这是最直接的实现。它对其他输入无效,但我已经解决了;请看我的编辑。 - maaartinus

11

你在实践中遇到的主要问题与这样一个事实相关,即round(a) + round(b)不一定等于round(a+b)。通过使用BigDecimal,您可以对舍入过程进行精细控制,因此可以使您的总和计算正确无误。

当您计算税费时,比如18%的增值税,很容易得到有超过两位小数的准确表示值。因此舍入成为了一个问题。

假设您购买2个单价为1.3美元的商品

Article  Price  Price+VAT (exact)  Price+VAT (rounded)
A        1.3    1.534              1.53
B        1.3    1.534              1.53
sum      2.6    3.068              3.06
exact rounded   3.07

如果您在进行双精度计算并只舍入以打印结果,则得到的总数为3.07,而账单上的金额实际上应该是3.06。


没错,但是货币输入已经被舍入了,所以当对结果进行舍入时,我得到的是精确值。 +++ 在增值税为18%的情况下,我必须将舍入位数从两位改为四位。这也是您在使用BigDecimal时必须考虑的事情。 - maaartinus
错误是由于中间舍入而导致的,只需进行一次舍入即可避免:round((1.3 * 1.18 + 1.3 * 1.18) * 1e2) / 1e2 - maaartinus
是的,当你被迫在账单上显示四舍五入的中间结果时,这正是问题所在。因此,在数学上更精确的结果(例如示例中的3.07)似乎是错误的。 - Henry
但我也可以得到另一个结果,而且逻辑很简单。每当我被迫展示一个我需要继续计算的值时,我就把不精确的值替换为四舍五入后的值:a = round((1.3 * 1.18) * 1e2) / 1e2; a+a - maaartinus
实际上,这类金融计算通常在非常特定的时间和以非常特定的方式进行舍入(否则你就会得到《超人III》)。 - Sneftel
无论您偏好哪种结果,我都可以轻松使用“double”完成。在每个步骤进行舍入可能会影响性能,但我敢打赌,它仍然比“BigDecimal”快。 - maaartinus

10
让我们用一种“较少技术性,更具哲学性”的方式回答这个问题:你认为“Cobol”在处理货币时为什么不使用浮点算法呢?(“Cobol”用引号括起来是指:现有的解决实际业务问题的旧方法。)
意思是:近50年前,人们开始将计算机用于商业金融工作时,很快意识到“浮点”表示法不能适用于金融行业(除了像问题中指出的一些罕见的小众领域)。请记住:那时候,抽象确实很昂贵!即使只是一个位或一个寄存器都很昂贵;然而,站在我们的前辈们的肩膀上,他们很快意识到使用“浮点数”无法解决他们的问题;他们必须依靠其他更抽象、更昂贵的东西!
我们的行业有50多年时间来研究“适用于货币的浮点数”,而通常的答案仍然是:不要这样做。相反,你可以求助于 BigDecimal 等其他解决方案。

7
您不需要例子,只需要掌握四年级的数学知识。浮点数中的分数是用二进制基数表示的,而二进制基数与十进制基数不可比。这是十年级的知识。因此总会有四舍五入和近似值,无论在任何情况下都不能接受会计上的这种情况。账目必须精确到最后一分钱,每天结束时银行分支机构以及整个银行定期进行FYI操作。引用:“一个因量化误差而产生的表达式并不算”,这是荒谬的。排除舍入误差就排除了整个问题。

我已经解释过了,我不排除舍入问题。 我要排除的是一个由诸如 “看这儿,1.0-0.9返回0.099999。我们 必须 使用BigDecimal 这样的示例组成的例子。在某些限制条件下,像这里一样的最终舍入可以证明是可修复的。 - maaartinus
我准确引用了你的话。FP舍入问题可以通过大量麻烦的修复来解决,而大多数程序员并不知道,在BigDecimal内部已经发生了这种情况(它在内部使用二进制,并带有三个警戒位:非常不平凡)。 - user207421
当然,您引用了我准确的话。感谢您指出了我的悬赏文本中存在误导性的表述。如果您能够的话,请随意改进它。如果您不能改进,那么请接受我后来的解释,因为我也无法编辑它。我的意思是仅仅列一个例子是不够的。+++ 我完全同意您说的使用“double”会带来很多麻烦,除非有充分的理由,否则我不建议这样做。但是,有些人已经成功地这么做了。 (https://dev59.com/vXRB5IYBdhLWcg3weHLx#PYnjnYgBc1ULPQZFIBW4) - maaartinus
公平地说,我不认为“二进制基数与十进制基数不可比较”是严格正确的。例如,二进制基数可以存储所有整数,就像十进制基数一样。对于任何精度的小数值,存在某个(相当合理的)二进制基数尾数大小,可以用于可逆地存储该类型的所有十进制小数值(例如,Double.toString(0.1)返回“0.1”,而不是类似于“0.99...995”的东西)。现在它可能不是“自然”的表示方式,但可以用于表示。 - BeeOnRope
BigDecimal 内部当然是使用“二进制”(间接通过 BigInteger)来表示其尾数,虽然比例尺固有的是十进制。我认为 double 的真正问题在于 操作 - 我认为 BigDecimal 上的大多数操作本质上都是精确的,而一些普通的 double 操作是不精确的,这些错误通常无法在信息丢失后修复。现在 BigDecimal 也有一些不精确的操作,但只有很少一部分(例如,重复小数的除法),因此您可以使用 BigDecimal 实现更多功能... 我想我最终同意了“不可比较”的观点? - BeeOnRope
显示剩余2条评论

4
假设您有1000000000001.5元(在1e12范围内),需要计算它的117%。 使用double计算得到的结果为1170000000001.7549(这个数字已经不精确了)。然后应用舍入算法,结果为1170000000001.75。
使用精确算术计算得到的结果是1170000000001.7550,四舍五入后变成了1170000000001.76。遗憾的是,您损失了1分钱。
我认为这是一个现实的例子,其中double比精确算术劣质。
当然,您可以想办法解决这个问题(甚至可以使用double运算实现BigDecimal,因此在某种程度上,double可以用于所有计算,并且将是精确的),但是这有何意义呢?
如果数字小于2 ^ 53,则可以使用double进行整数运算。只要您能够在这些限制范围内表达您的数学问题,那么计算就会是准确的(当然,除法需要特别注意)。一旦您离开这个领域,您的计算就可能不准确了。
正如您所看到的,53位不够用,double也不够用。但是,如果您将货币存储为十进制固定点数(我指的是存储数字money*100,如果需要分的精度),那么64位就足够了,因此可以使用64位整数代替BigDecimal。

0

在处理高价值的数字货币(如比特币、莱特币等)、股票等形式时,使用BigDecimal是非常必要的。在这些情况下,您很多时候会处理到7或8个有效位数的特定值。如果您的代码意外地导致3或4个有效位数的舍入误差,那么损失可能非常严重。因为舍入误差而损失资金不会带来乐趣,尤其是对于客户。

当然,如果您确保一切都做得正确,可能可以只使用Double来处理所有事情,但最好还是不冒险,尤其是从零开始。


我不同意。比特币有8个小数位,只要你没有超过一千万个,使用double类型就可以了。当累积误差太大时,将中间结果四舍五入到8个小数位即可。 - maaartinus
没错,如果舍入误差出现在小数点后第8位,那就没问题了。但是,如果你在小数点后第二或第三位出现了舍入误差,那么你的问题每个小数位自动恶化了10倍。 - Ryan - Llaver
不,我的建议是基于以下知识进行精确计算:小数点后不超过八位。例如,当a = 0.00000001时,我们得到a+a+a3.0000000000000004e-8,这略有偏差。误差可能会累积,但是当我们四舍五入时,我们得到的3e-8是可以完全表示的。在输出时进行四舍五入,我们得到了没有任何错误的"0.00000003" - maaartinus
啊,好的,我明白你的意思了。 - Ryan - Llaver

0

下面的代码似乎是一个不错的实现,可以将数值“向下舍入到最接近的一分钱”。

private static double roundDowntoPenny(double d ) {
    double e = d * 100;
    return ((int)e) / 100.0;
}

然而,以下的输出显示行为并不完全符合我们的预期。
public static void main(String[] args) {
    System.out.println(roundDowntoPenny(10.30001));
    System.out.println(roundDowntoPenny(10.3000));
    System.out.println(roundDowntoPenny(10.20001));
    System.out.println(roundDowntoPenny(10.2000));
}

输出:

10.3
10.3
10.2
10.19 // Not expected!

当然,我们可以编写一个方法来产生我们想要的输出。问题在于这实际上非常困难(并且在需要操作价格的每个地方都很困难)。

对于每个数字系统(十进制、二进制、十六进制等),只要有限数量的数字,就存在无法精确存储的有理数。例如,1/3在十进制中无法用有限的数字存储。同样,3/10在二进制中也无法用有限的数字存储。

如果我们需要选择一种数字系统来存储任意有理数,那么选择什么系统都无所谓——任何选择的系统都会有一些无法精确存储的有理数。

然而,在计算机系统开发之前,人类就已经开始给事物定价了。因此,我们看到的价格是像5.30这样的,而不是5 + 1/3。例如,我们的股票交易所使用十进制价格,这意味着它们只接受可以用十进制表示的价格的订单和报价。同样,这意味着它们可以在不能在二进制中准确表示的价格上发布报价和接受订单。

通过将这些价格以二进制方式存储(传输,操作),我们实质上依靠四舍五入逻辑将我们的(不精确的)基于二进制的(表示)数字始终正确地舍入到它们的(精确的)十进制表示形式。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接