一个实际的例子,使用 BigDecimal 作为货币比使用 double 更好

A realistic example where using BigDecimal for currency is strictly better than using double

我们 know 使用 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);

不算数,因为它们通过四舍五入很容易解决(在这个例子中,从零到十六位小数都可以)。


涉及大值求和的计算可能需要一些中间舍入,但考虑到 total currency in circulation being USD 1e12, Java double (i.e., the standard IEEE double precision) 及其 15 位十进制数字对于美分来说仍然足够。


即使使用 BigDecimal,涉及除法的计算通常也不精确。我可以构建一个doubles无法执行但可以使用BigDecimal执行的计算,使用100的比例,但这不是你在现实中可以遇到的。


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

我也肯定同意,使用 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;
}

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

你在实践中面临的主要问题与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

所以如果你用double和only round来打印结果,你会得到总共3.07,而账单上的金额实际上应该是3.06。

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

这是因为double 0.615实际上是0.6149999999999999911182158029987476766109466552734375.

您不需要示例。你只需要四年级的数学。浮点数中的分数用二进制基数表示,二进制基数与十进制基数不可通约。十年级的东西。

因此,四舍五入和近似值总是存在的,在会计中无论以何种方式、形状或形式都是不可接受的。账簿必须平衡到最后一分钱,因此仅供参考,每天结束时都会在银行分行,并定期检查整个银行。

an expression suffering from round-off errors doesn't count'

荒谬。这 的问题。排除舍入误差就排除了整个问题。

在处理加密货币(BTC、LTC 等)、股票等高价值数字形式的货币时,使用 BigDecimal 是最必要的。在这种情况下,很多时候你会处理非常具体数值保留 7 位或 8 位有效数字。如果您的代码不小心导致 3 或 4 个 sig figs 的舍入错误,那么损失可能会非常大。由于舍入误差而赔钱并不有趣,尤其是对于客户而言。

当然,如果您确保每件事都做对了,您可能可以对所有事情都使用 Double,但最好不要冒险,尤其是如果您是从头开始的话。

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

尾数太小

由于尾数中的精度为 ~15 位小数位,因此每当您处理大于此的金额时,您都会得到错误的结果。如果您跟踪美分,问题将在 1013(十万亿)美元之前开始出现。

虽然这是一个很大的数字,但没有那么大。美国约 18 万亿的 GDP 超过了它,因此任何处理国家甚至公司规模的事情都可能很容易得到错误的答案。

此外,在计算过程中,有很多方法可以让更小的金额超过此阈值。您可能会进行多年的增长预测或预测,这会产生很大的最终价值。您可能正在进行 "what if" 场景分析,其中检查了各种可能的参数,并且某些参数组合可能会产生非常大的值。您可能根据允许 fractions of a cent which could chop another two orders of magnitude or more off of your range, putting you roughly in line with the wealth of mere individuals 美元的财务规则工作。

最后,我们不要以美国为中心来看待事物。其他货币呢?一美元的价值是 worth roughly 13,000 印度尼西亚卢比,因此您需要另外 2 个数量级来跟踪该货币的货币金额(假设没有 "cents"!)。您几乎已经达到凡人感兴趣的数量。

这里 an example 从 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)

增量(doubleBigDecimal 之间的差异在第 160 年首次达到 > 1 美分,大约 2 万亿(从现在起 160 年后可能不会那么多),当然只会越来越糟。

当然,尾数的53位意味着这种计算的relative误差很可能很小(希望你不要超过1丢了工作2 万亿中的 1%)。实际上,在大多数示例中,相对误差基本上保持相当稳定。你当然可以组织它,这样你(例如)减去两个不同的尾数精度损失导致任意大的错误(练习高达 reader)。

改变语义

所以你认为你很聪明,并设法想出了一个舍入方案,让你可以使用 double 并且已经在你的本地 JVM 上详尽地测试了你的方法。继续部署它。明天或下周或任何对你来说最糟糕的时候,结果都会改变,你的把戏也会失败。

与几乎所有其他基本语言表达式不同,当然也与整数或 BigDecimal 算术不同,默认情况下,由于 strictfp feature. Platforms are free to use, at their discretion, higher precision intermediates, which may result in different results on different hardware, JVM versions, etc. The result, for the same inputs, may even vary at runtime,许多浮点表达式的结果没有单一的标准定义值当方法从解释切换到 JIT 编译时!

如果您在 Java 1.2 之前的日子里编写过代码,当 Java 1.2 突然引入现在默认的变量 FP 行为时,您会非常生气。您可能会想在任何地方都使用 strictfp 并希望您不要 运行 进入任何 multitude of related bugs - 但在某些平台上,您会丢掉很多翻倍的性能一开始就买了你。

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

不精确的表示

正如 Roland 在他的回答中指出的那样,double 的一个关键问题是它没有对某些大多数非整数值的精确表示。尽管在某些情况下(例如 Double.toString(0.1).equals("0.1")),像 0.1 这样的单个非精确值通常会 "roundtrip" OK,但一旦您对这些不精确的值进行数学计算,错误就会复合,并且这可能是无法恢复的。

特别是,如果您 "close" 到一个舍入点,例如 ~1.005,您可能会得到一个值 1.00499999...当真实值为 1.0050000001...时, 或反之亦然 。因为错误是双向的,所以没有可以解决这个问题的舍入魔法。没有办法判断 1.004999999 的值是否应该提高。您的 roundToTwoPlaces() 方法(一种双舍入)之所以有效,是因为它处理了应该提高 1.0049999 的情况,但它永远无法越过边界,例如,如果累积错误导致 1.0050000000001 变成1.00499999999999 无法修复。

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

根据此处的要求 a searching test 进行简单计算:amount * tax 并将其四舍五入到小数点后两位(即美元和美分)。那里有一些舍入方法,目前使用的 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

请注意 "raw result"(即精确的未舍入结果)始终接近 x.xx5000 边界。您的舍入方法在高边和低边都有错误。你不能一般地修复它。

不精确的计算

一些 java.lang.Math 方法不需要正确舍入结果,但允许高达 2.5 ulp 的误差。诚然,您可能不会大量使用双曲线函数来处理货币,但是 exp()pow() 等函数经常用于货币计算,并且这些函数的精度仅为 1 ulp。所以返回的时候已经是"wrong"了。

这与 "Inexact Representation" 问题相互作用,因为这种类型的错误比正常数学运算中的错误严重得多,正常数学运算至少从 [=12 的可表示域中选择最佳可能值=].意味着使用这些方法,你可以有更多的轮界穿越事件。

让我们在这里给出一个 "less technical, more philosophical" 的答案:为什么你认为 "Cobol" 在处理货币时没有使用浮点运算?!

("Cobol" 在引号中,如:解决现实世界业务问题的现有遗留方法)。

意思是:将近 50 年前,当人们开始使用计算机从事商业或财务工作时,他们很快意识到 "floating point" 表示法不适用于金融业(可能会出现一些罕见的小众角落正如问题中指出的那样)。

请记住:那时候,抽象 真的很昂贵!这里有一点,那里有一个寄存器,这已经够贵了;对于我们站在他们肩膀上的巨人来说,这一点很快就会变得显而易见……使用 "floating points" 不会解决他们的问题;他们不得不依赖其他东西;更抽象 - 更昂贵!

我们的行业有 50 多年的时间才想出 "floating point that works for currency" - 常见的 答案仍然是:不要这样做。相反,您求助于 BigDecimal 等解决方案。

假设您有 1000000000001.5(在 1e12 范围内)钱。你必须计算其中的117%。

在double中,变成1170000000001.7549(这个数字已经不精确)。然后应用你的轮算法,它变成 1170000000001.75.

精确算术为1170000000001.7550,四舍五入为1170000000001.76。 哎呀,你输了 1 美分。

我认为这是一个现实的例子,double 不如精确的算术。

当然,您可以通过某种方式解决此问题(甚至,您可以使用双精度算术来实现 BigDecimal,因此在某种程度上,双精度可以用于任何事情,而且它会很精确),但这有什么意义呢?

如果数字小于 2^53,您可以使用 double 进行整数运算。如果你能在这些限制范围内表达你的数学,那么计算就会很精确(当然除法需要特别小心)。一旦离开这个领域,你的计算就会不准确。

如你所见,53 位不够,double 不够。但是,如果您将钱存储在十进制定点数中(我的意思是,存储数字 money*100,如果您需要美分精度),那么 64 位可能就足够了,因此可以使用 64 位整数代替BigDecimal.

以下似乎是 "round down to the nearest penny" 所需方法的一个不错的实现。

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!

当然,可以编写一个方法来产生我们想要的输出。问题是实际上很难做到(而且在每个需要操纵价格的地方都这样做)。

对于每个位数有限的数字系统(base-10、base-2、base-16 等),都有无法准确存储的有理数。例如,1/3 不能以 10 进制存储(使用有限位)。类似地,3/10 不能以 base-2 存储(使用有限位)。

如果我们需要选择一个数字系统来存储任意有理数,那么我们选择什么系统并不重要——任何选择的系统都会有一些无法准确存储的有理数。

然而,人类在计算机系统开发之前就开始为事物定价。因此,我们看到的价格是 5.30 而不是 5 + 1/3。例如,我们的证券交易所使用十进制价格,这意味着它们只接受可以以 10 为基数表示的价格的订单和报价。同样,这意味着他们可以以 base-2 无法准确表示的价格发布报价和接受订单。

通过以 2 为基数存储(传输、操纵)这些价格,我们基本上依靠舍入逻辑始终正确地将我们的(不精确的)以 2 为基数(表示)的数字舍入到它们的(精确)以 10 为基数表示。