一个现实的例子,其中使用BigDecimal作为货币严格优于使用双精度曼蒂萨太小更改语义不准确的表示不精确的计算

2022-09-01 00:31:03

我们知道,使用货币容易出错,不建议这样做。但是,我还没有看到一个现实的例子,其中工作失败,不能简单地通过一些四舍五入来修复。doubleBigDecimaldouble


请注意,琐碎的问题

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);

不要计算在内,因为它们是通过四舍五入轻松解决的(在这个例子中,从零到十六位的小数都可以)。


涉及对大值求和的计算可能需要一些中间值,但考虑到流通中的总货币是,Java(即标准的IEEE双精度)及其15个小数位仍然足以表示美分。USD 1e12double


即使使用 ,涉及除法的计算通常也不精确。我可以构造一个不能用s执行的计算,但可以使用100的刻度来执行,但这不是你在现实中可以遇到的。BigDecimaldoubleBigDecimal


我并不是说这样一个现实的例子不存在,只是我还没有看到它。

我也肯定同意,使用更容易出错。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;
}

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


答案 1

我可以看到在处理货币计算时可以搞砸你的四种基本方法。double

曼蒂萨太小

尾数中精度为约15位十进制数字,因此每次处理大于该值的金额时,您都会得到错误的结果。如果你正在跟踪美分,问题将在1013(十万亿)美元之前开始发生。

虽然这是一个很大的数字,但并不是那么大。美国GDP约为18万亿,因此任何涉及国家甚至公司规模金额的事情都很容易得到错误的答案。

此外,在计算过程中,有很多方法可以使较小的金额超过此阈值。您可能正在执行增长预测或多年,这会产生较大的最终值。您可能正在执行“假设”方案分析,其中检查了各种可能的参数,并且参数的某些组合可能会导致非常大的值。你可能在财务规则下工作,允许一美分的一小部分,这可能会从你的范围中再削减两个数量级或更多,使你大致符合美元中个人财富。

最后,我们不要以美国为中心的观点。其他货币呢?一美元的价值大约相当于13,000印尼盾,所以这是另外2个数量级,你需要跟踪该货币的货币金额(假设没有“美分”!你几乎已经降到了凡人感兴趣的金额。

下面是一个示例,其中从 1e9 开始的 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)

三角洲(与第一次之间的差异在160年达到1美分>,大约2万亿(可能不是160年后的全部),当然只会变得更糟。doubleBigDecimal

当然,53位的Mantissa意味着这种计算的相对误差可能非常小(希望你不会失去2万亿分之一的工作)。事实上,相对误差在大部分示例中基本上保持相当稳定。您当然可以组织它,以便(例如)减去两个变量,并在尾数中损失精度,从而导致任意大的错误(练习到读者)。

更改语义

因此,您认为自己非常聪明,并设法提出了一个舍入方案,允许您在本地JVM上使用并详尽地测试您的方法。继续部署它。明天或下周,或者任何时候对你来说最糟糕的时候,结果都会改变,你的伎俩就会失败。double

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

如果你在Java之前的1.2天里编写了你的代码,那么当Java 1.2突然引入现在默认的变量FP行为时,你会非常生气。你可能会想在任何地方使用,并希望你不会遇到任何相关的错误 - 但在某些平台上,你会抛弃许多性能,而这些性能本来就给你带来了双重好处。strictfp

没有什么可以说JVM规范将来不会再次更改以适应FP硬件的进一步变化,或者JVM实现者不会使用默认的非严格fp行为给他们的绳索来做一些棘手的事情。

不准确的表示

正如Roland在他的答案中指出的那样,一个关键问题是它对一些大多数非整数值没有精确的表示。虽然在某些情况下(例如,)一个非精确值通常会“往返”OK,但一旦你对这些不精确的值进行数学计算,误差就会加剧,这可能是不可恢复的。double0.1Double.toString(0.1).equals("0.1")

特别是,如果您“接近”一个舍入点,例如~1.005,您可能会得到1.00499999...当真实值为 1.0050000001...时,反之亦然。由于错误是双向的,因此没有四舍五入的魔法可以解决此问题。没有办法判断值 1.004999999...是否应该颠簸。你的方法(一种双舍入)之所以有效,是因为它处理了1.0049999应该被颠簸的情况,但它永远无法越过边界,例如,如果累积错误导致1.005000000000001变成1.004999999999999,它无法修复它。roundToTwoPlaces()

你不需要大或小的数字来达到这个目标。您只需要一些数学运算,结果就可以接近边界。你做的数学运算越多,与真实结果的可能偏差就越大,跨越边界的机会就越大。

按照此处的要求,执行简单计算的搜索测试:并将其四舍五入到小数点后2位(即美元和美分)。那里有一些舍入方法,目前使用的一种是你的1的增强版本(通过增加第一个舍入的乘数,你使它更加敏感 - 原始版本在琐碎的输入上立即失败)。amount * taxroundToTwoPlacesBn

测试会吐出它发现的失败,它们成群结队地出现。例如,前几个失败:

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

请注意,“原始结果”(即确切的未舍入结果)始终接近边界。您的舍入方法在高边和低边都是错误的。您无法一般地修复它。x.xx5000

不精确的计算

一些方法不需要正确舍入的结果,而是允许最多2.5 ulp的错误。当然,您可能不会在货币中使用过多的双曲函数,但是诸如和之类的函数经常会进入货币计算,而这些函数的准确度仅为1 ulp。因此,当返回时,该数字已经是“错误的”。java.lang.Mathexp()pow()

这与“不精确表示”问题相互作用,因为这种类型的错误比正常的数学运算中的错误严重得多,这些运算至少从可表示域中选择最佳值。这意味着,当您使用这些方法时,您可以拥有更多的圆形边界交叉事件。double


答案 2

当您舍入到两个小数位时,您将获得0.61(向下舍入),但可能期望为0.62(向上舍入,因为5)。double price = 0.615

这是因为双精度0.615实际上是0.614999999999999999911182158029987476766109466552734375。