使用BigDecimal作为货币的一个现实例子明显优于使用double

问题描述 投票:39回答:9

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

不要计算,因为它们通过舍入(在这个例子中从0到16的小数位的任何东西)都可以解决。


涉及对大值求和的计算可能需要一些中间轮询,但鉴于total currency in circulationUSD 1e12,Java double(即标准IEEE double precision)及其15位十进制数字仍然是美分的足够事件。


即使使用BigDecimal,涉及分工的计算通常也是不精确的。我可以构建一个不能用doubles执行的计算,但可以使用BigDecimal使用100的标度执行,但它不是你在现实中可以遇到的东西。


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

我也肯定同意,使用double更容易出错。

Example

我正在寻找的是如下方法(根据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;
}

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

java floating-point currency biginteger
9个回答
24
投票

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

Mantissa Too Small

尾数中精度约为15位十进制数,只要你处理的金额大于此值,你就会得到错误的结果。如果你跟踪美分,问题将在1013(10万亿)美元之前开始出现。

虽然这是一个很大的数字,但并不是那么大。美国的国内生产总值约为18万亿美元,超过它,因此任何处理国家甚至公司规模的金额都很容易得到错误的答案。

此外,有很多方法可以在计算过程中将更小的数量超过此阈值。您可能正在进行增长预测或多年,这会产生较大的最终价值。您可能正在进行“假设”情景分析,其中检查各种可能的参数,并且某些参数组合可能导致非常大的值。您可能正在根据财务规则开展工作,这些规则允许fractions of a cent可以将您的范围再削减两个数量级或更多,这使您大致与美元的mere individuals财富保持一致。

最后,我们不要以美国为中心的观点。其他货币怎么样?印尼盾是worth roughly 13,000美元,所以你需要跟踪该货币的货币金额另外2个数量级(假设没有“美分”!)。你几乎要达到凡人所感兴趣的金额。

这是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位的Mantissa意味着这种计算的相对误差可能非常小(希望你的工作不会超过2万亿美元)。实际上,在大多数示例中,相对误差基本上保持稳定。你当然可以组织它,以便你(例如)在尾数中减去两个不同的精度,导致一个任意大的错误(练习到读者)。

Changing Semantics

所以你认为你很聪明,并设法提出一个舍入方案,让你使用double并在本地JVM上详尽测试你的方法。继续部署它。明天或下周,或者每当你最糟糕的时候,结果都会改变,你的技巧会被打破。

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

如果您在Java之前的1.2天编写了代码,那么当Java 1.2突然引入now-default变量FP行为时,您会非常生气。你可能想要在任何地方使用strictfp,并希望你不要遇到任何multitude of related bugs - 但在某些平台上,你会丢掉大部分双重购买你的性能。

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

Inexact Representations

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

特别是,如果您“接近”舍入点,例如~1.005,则可能得到值1.00499999 ...当真值为1.0050000001 ...时,反之亦然。因为错误是双向的,所以没有可以解决这个问题的舍入魔法。没有办法判断是否应该增加1.004999999 ......的值。你的roundToTwoPlaces()方法(一种双舍入)只能起作用,因为它处理的情况是1.0049999应该被提升,但它永远不能越过边界,例如,如果累积误差导致1.0050000000001变成1.00499999999999就可以'解决它。

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

根据要求,a searching test做了一个简单的计算:amount * tax并将其舍入到2位小数(即美元和美分)。有一些舍入方法,目前使用的那个,roundToTwoPlacesB是yours1的加强版本(通过在第一轮中增加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

注意,“原始结果”(即精确的未结果)始终接近x.xx5000边界。你的舍入方法在高低两侧都会出错。你无法解决这个问题。

Imprecise Calculations

一些java.lang.Math方法不需要正确的舍入结果,而是允许高达2.5 ulp的错误。当然,您可能不会使用货币的双曲线函数,但exp()pow()等函数经常会进入货币计算,这些函数的精确度仅为1 ulp。因此,返回时该数字已经“错误”。

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


19
投票

当你将double price = 0.615四舍五入到小数点后两位时,得到0.61(向下舍入),但可能预期为0.62(由于5而被四舍五入)。

这是因为双0.615实际上是0.6149999999999999911182158029987476766109466552734375。


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

因此,如果您使用double进行计算并且仅使用round来打印结果,那么您将获得总计3.07,而账单上的金额实际上应该是3.06。


9
投票

让我们在这里给出一个“技术性较低,更具哲学性”的答案:为什么你认为“Cobol”在处理货币时不使用浮点运算?!

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

含义:差不多50年前,当人们开始使用计算机进行业务而非金融工作时,他们很快意识到“浮点”表示对金融行业不起作用(可能期望在问题中指出一些罕见的利基角落) )。

请记住:那时候,抽象真的很贵!它的价格足够昂贵,并且在那里有一个寄存器;我们站在他们的肩膀上的巨人们很快就会明白......使用“浮点”并不能解决他们的问题;并且他们不得不依赖别的东西;更抽象 - 更贵!

我们的行业有50多年的时间来提出“适用于货币的浮动点” - 而且常见的答案仍然是:不要这样做。相反,你转向BigDecimal等解决方案。


6
投票

你不需要一个例子。你只需要第四种形式的数学。浮点中的分数以二进制基数表示,二进制基数与十进制基数不可比较。十年级的东西。

因此,总会有舍入和近似,并且在任何方式,形状或形式的会计中都不可接受。这些书必须平衡到最后一分钱,因此,FYI在每天结束时和整个银行定期进行银行分行。

遭受四舍五入错误的表达不计算在内

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


3
投票

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

在双倍,它变成1170000000001.7549(这个数字已经不精确)。然后应用你的圆算法,它变成1170000000001.75。

在精确算术中,它变为1170000000001.7550,其舍入为1170000000001.76。哎哟,你输了1美分。

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

当然,你可以用某种方式解决这个问题(甚至,你可以使用double arihmetic来实现BigDecimal,所以在某种程度上,double可以用于所有事情,并且它将是精确的),但重点是什么?

如果数字小于2 ^ 53,则可以使用double进行整数运算。如果你能在这些约束条件下表达你的数学,那么计算将是精确的(当然,除法需要特别小心)。一旦离开这片领土,你的计算就会变得不精确。

如你所见,53位是不够的,双重是不够的。但是,如果你用十进制固定点数存储货币(我的意思是,存储数字money*100,如果你需要美分精度),那么64位可能就够了,所以可以使用64位整数代替BigDecimal


0
投票

在处理高价值数字形式的货币(如cyprtocurrency(BTC,LTC等),股票等)时,使用BigDecimal是最有必要的。在这种情况下,很多时候你会在7或者处理非常具体的价值。 8位重要人物。如果您的代码意外地导致3或4 sig figs的舍入误差,则损失可能非常大。因为舍入错误而亏钱并不会很有趣,特别是如果它是针对客户的。

当然,如果你确保一切正确的话,你可能可以使用Double来实现一切,但最好不要冒风险,特别是如果你从头开始。


0
投票

底线前线:

double失败的简单现实例子:

所有较大的数值类型都可以通过较小的数字类型使用较小数字类型的列表完美地模拟,并维护诸如符号和小数位的记录。因此,数字类型仅在使用时失败等同于更高的代码复杂度和/或更慢的速度。

当你知道如何处理BigDecimal乘法和除法以避免下溢时,double不会降低代码复杂度。但是,可能存在BigDecimal可能比double更快的情况。

但是,应该没有strictly更好(在数学意义上)比双倍更好的情况。为什么?因为double计算是在现代处理器中实现的单元操作(在一个周期内),因此任何有效的大精度浮点计算,在其基础上,要么使用某种double-esque数值类型,要么慢于最优。

换句话说,如果double是砖,则BigDecimal是一堆砖。



所以,首先,让我们定义“double对财务分析不利”的背景下“坏”的含义。


double浮点数是二进制状态列表。因此,如果您只能访问类和32位整数,则只需记录小数,符号等的位置并维护整数列表,即可“重新创建”double

这个过程的缺点是你需要一个更复杂和错误的代码库来管理它。此外,double等于64位处理器的字大小,因此当您的类包含整数列表时,计算速度会变慢。



现在,计算机速度非常快。除非你正在编写草率代码,否则你不会注意到double和你的类之间的区别,它的O(n)操作的整数列表(一个用于循环)。

因此,这里的主要问题是编写代码复杂性(使用的复杂性,阅读等)。



由于代码复杂性是主要问题,因此请考虑将分数乘以多次的财务状况。

这可能会导致下溢,这是您正在谈论的舍入错误。

下溢的修复是获取日志:

// small numbers a and b
double a = ...
double b = ...

double underflowed_number = a*pow(b,15); // this is potentially an inaccurate calculation. 

double accurate_number = pow(e,log(a) + 15*log(b)); // this is accurate

现在,问题是:您处理的代码复杂性太多了吗?

或者,更好的是:您的同事处理的复杂性是否过高?有人会过来说:“哇,这看起来真的很低效,我会把它改回a*pow(b,15)”?

如果是,那么只需使用BigDecimal;否则:除了下溢计算之外,double将在使用和语法方面减轻重量......而且编写的代码复杂性实际上并不是那么大的交易。


有一点需要注意:如果你在计算复杂的设置中进行涉及下溢解决方法的重要计算,例如在银行后端运行的某个内部子程序上嵌套for循环,那么你应该使用BigDecimal进行测试,因为这实际上可能是快点。

所以你的问题的答案:

// at some point, for some large_number this *might* be slower, 
// depending on hardware, and should be tested:
for (i=1; i<large_number; i++){
    for(j=1;j<large_number;j++){
        for(k=1;k<large_number;k++){
            // switched log to base 2 for speed
            double n = pow(2,log2(a) + 15*log2(b));
        }
    }
}

// this *might* be faster:
for (i=1; i<large_number; i++){
    for(j=1;j<large_number;j++){
        for(k=1;k<large_number;k++){
            BigDecimal n = a * pow(b,15);
        }
    }
}

如果我有时间的话,我会添加一个渐近的情节。


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!

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

对于具有有限位数的每个数字系统(基数为10,基数为2,基数为16等),存在无法精确存储的有理数。例如,1/3不能在base-10中存储(有限数字)。同样,3-10不能在base-2中存储(有限数字)。

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

然而,人类开始在计算机系统开发之前为物品分配价格。因此,我们认为价格为5.30而不是5 + 1/3。例如,我们的证券交易所使用十进制价格,这意味着他们接受订单并发出报价,仅在可以用10表示的价格中。同样,这意味着他们可以发出报价并接受无法在base-2中准确表示的价格的订单。

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

© www.soinside.com 2019 - 2024. All rights reserved.