使用BigDecimal处理货币

72

我试图使用 long 来创建自己的货币类,但显然应该使用 BigDecimal。有人能帮我入门吗?对于像美元这样的货币,使用 BigDecimal 的最佳方法是什么,如何将其设置为至少但不超过两位小数点等。 BigDecimal 的 API 很大,我不知道应该使用哪些方法。另外,BigDecimal 具有更好的精度,但如果它通过 double,这一切是否都会丢失?如果我使用 new BigDecimal(24.99),与使用 double 有何不同?还是应该使用使用 String 的构造函数?


1
一切看起来都很好,但还有最后一件事 - 当您在BigDecimal实例上使用setScale()时,特别是如果您稍后将其用于加法,则提供n + 1的舍入因子,其中n是有效数字的数量。例如,为以下加法设置四舍五入因子为3,0.043 + 0.043将产生0.09而不是0.08(如果您选择了2个有效数字进行舍入/存储)。 - Vineet Reynolds
在前面的例子中,如果您选择了2个有效数字,并在加法之前执行了舍入(作为中间操作),那么您将得到0.04 + 0.04 = 0.08。 - Vineet Reynolds
存储从价格中减去的百分比的变量是否也应该是 BigDecimal? - mk12
9个回答

95
以下是一些提示:
  1. 如果需要精度,请使用BigDecimal进行计算(货币值通常需要此功能)。
  2. NumberFormat类进行显示。该类将处理不同货币金额的本地化问题。但是,它只接受原始类型;因此,如果您可以接受由于转换为double而导致的小精度变化,则可以使用此类。
  3. 在使用NumberFormat类时,使用BigDecimal实例上的scale()方法设置精度和舍入方法。

附注:如果您想知道,在Java中表示货币值时,BigDecimal始终优于double

PPS:

创建BigDecimal实例

这很简单,因为BigDecimal提供了构造函数来接受原始值String对象。你可以使用它们,最好是使用接受String对象的那个。例如,
BigDecimal modelVal = new BigDecimal("24.455");
BigDecimal displayVal = modelVal.setScale(2, RoundingMode.HALF_EVEN);

显示 BigDecimal 实例

您可以使用setMinimumFractionDigitssetMaximumFractionDigits方法调用来限制显示的数据量。

NumberFormat usdCostFormat = NumberFormat.getCurrencyInstance(Locale.US);
usdCostFormat.setMinimumFractionDigits( 1 );
usdCostFormat.setMaximumFractionDigits( 2 );
System.out.println( usdCostFormat.format(displayVal.doubleValue()) );

3
特别是在第二点上我同意。将视图(用于显示的格式)与模型(BigDecimal)分开。您可以编写自定义的java.text.Format来处理您特定的数据类型。 - Steve Kuo
8
尽管文档上说BigDecimal(double)构造函数是“不可预测的”,但使用它来创建BigDecimal对象时会出现-1等问题。 - Ryan Fernandes
抱歉评论有点长,但我剩下的问题是,我应该只使用NumberFormat并将其舍入模式设置为half_even进行显示,然后如果需要模型数据,在计算结束时进行setScale,还是应该仅使用NumberFormat进行货币数字格式化,并在开始时对BigDecimal进行setScale? - mk12
1
让我把你的问题重新表述为“何时应该对BigDecimal值进行四舍五入?”这将取决于您如何使用数字-加法和减法需要存储较少的小数位,而乘法和除法则需要更多。实际上,您需要额外的1个小数位用于加法和减法(以处理溢出的四舍五入)。单位成本不应四舍五入,转换单位也不应该。总的来说,这取决于您在BigDecimal实例中存储的内容。如果您需要在整个操作过程中保留精度,请在最后执行舍入。 - Vineet Reynolds
1
理想情况下,您应该在操作结束时进行四舍五入,并将该值存储在数据库中(通常用于金融服务领域的审计),以便显示的值和存储在数据库中的值保持一致。因此,您已经得到了答案,只需使用数字格式进行货币格式化,并在开头(如果需要)和结尾(存储之前)使用setScale。删除四舍五入操作将使计算的数学正确性得到验证。 - Vineet Reynolds
显示剩余20条评论

41

我建议你对"Money Pattern"进行一些研究。Martin Fowler在他的书《分析模式》中对此进行了更详细的介绍。

public class Money {

    private static final Currency USD = Currency.getInstance("USD");
    private static final RoundingMode DEFAULT_ROUNDING = RoundingMode.HALF_EVEN;

    private final BigDecimal amount;
    private final Currency currency;   

    public static Money dollars(BigDecimal amount) {
        return new Money(amount, USD);
    }

    Money(BigDecimal amount, Currency currency) {
        this(amount, currency, DEFAULT_ROUNDING);
    }

    Money(BigDecimal amount, Currency currency, RoundingMode rounding) {
        this.currency = currency;      
        this.amount = amount.setScale(currency.getDefaultFractionDigits(), rounding);
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public Currency getCurrency() {
        return currency;
    }

    @Override
    public String toString() {
        return getCurrency().getSymbol() + " " + getAmount();
    }

    public String toString(Locale locale) {
        return getCurrency().getSymbol(locale) + " " + getAmount();
    }   
}

使用方法如下:

您应该使用Money对象来表示所有货币,而不是使用BigDecimal。采用大十进制数表示货币意味着您必须在显示货币的任何地方都要进行格式化。想象一下如果显示标准更改了,那么您将不得不在所有地方进行编辑。相反,使用Money模式可以将货币的格式集中到一个单一位置。

Money price = Money.dollars(38.28);
System.out.println(price);

4
这里的一个问题是,并非所有货币在金额前都使用符号,或者在它们之间留有空格。这些应该成为格式设置的一部分。 - Gabe Sechan
1
没错,@GabeSechan - 这可能会让你感兴趣 https://drivy.engineering/multi-currency-java/ - Mick

12

或者,等待JSR-354。Java货币和货币API即将推出!


5
你讨厌那些不留评论就把好的建议否决掉的人吗! - Victor Grazi
2
詹姆斯·德林卡德,情况并非如此。JSR 354计划很快作为独立库发布。最初的计划是等待Java 9,但很快将发布一个临时库。 - Victor Grazi
2
也许是Java 2035? - th3byrdm4n
3
我该说什么呢,我不再是那个规范的领导者了。 - Victor Grazi

1
原始数字类型对于在内存中存储单个值非常有用。但是,在使用双精度和浮点类型进行计算时,存在舍入问题。这是因为内存表示不能完全映射到该值。例如,双精度值应该占用64位,但Java并没有使用所有64位。它只存储它认为重要的数字部分。因此,当您将float或double类型的值相加时,可能会得到错误的值。
请查看一个短片https://youtu.be/EXxUSz9x7BM

1

1) 如果您受限于双精度,使用BigDecimal的一个原因是可以实现从double创建的BigDecimal进行操作。

2) BigDecimal由任意精度整数未缩放值和非负32位整数比例组成,而double将基本类型double的值包装在对象中。 Double类型的对象包含一个类型为double的单个字段。

3) 这应该没有区别

您不应该遇到$和精度方面的困难。一种方法是使用System.out.printf。


1

当你想要将金额四舍五入到2位小数点时,请使用BigDecimal.setScale(2, BigDecimal.ROUND_HALF_UP)。但是在进行计算时要注意四舍五入误差。在对货币值进行四舍五入时,您需要保持一致性。可以在所有计算完成后仅在最后一次进行四舍五入,也可以在进行任何计算之前对每个值应用四舍五入。哪种方法取决于您的业务需求,但通常我认为在最后进行四舍五入似乎更合理。

在构造货币值的BigDecimal时,请使用String。如果使用double,则末尾会有浮点值。这是由于计算机体系结构关于如何以二进制格式表示double/float值的原因。


11
请注意,对于金融应用程序而言,ROUND_HALF_EVEN 是最常见的舍入模式,因为它避免了偏差。 - Michael Borgwardt
避免偏见的好观点。不知道它是如何实现的。 - Vineet Reynolds
@Michael:谢谢你的提示。我不知道这个。我一直想知道如何最好地处理偏差/累积误差。今天学到了新东西。 :) - tim_wonil

1

我将采用激进的方式,不使用BigDecimal。

这是一篇很棒的文章https://lemnik.wordpress.com/2011/03/25/bigdecimal-and-your-money/

来自这里的想法。

import java.math.BigDecimal;

public class Main {

    public static void main(String[] args) {
        testConstructors();
        testEqualsAndCompare();
        testArithmetic();
    }

    private static void testEqualsAndCompare() {
        final BigDecimal zero = new BigDecimal("0.0");
        final BigDecimal zerozero = new BigDecimal("0.00");

        boolean zerosAreEqual = zero.equals(zerozero);
        boolean zerosAreEqual2 = zerozero.equals(zero);

        System.out.println("zerosAreEqual: " + zerosAreEqual + " " + zerosAreEqual2);

        int zerosCompare = zero.compareTo(zerozero);
        int zerosCompare2 = zerozero.compareTo(zero);
        System.out.println("zerosCompare: " + zerosCompare + " " + zerosCompare2);
    }

    private static void testArithmetic() {
        try {
            BigDecimal value = new BigDecimal(1);
            value = value.divide(new BigDecimal(3));
            System.out.println(value);
        } catch (ArithmeticException e) {
            System.out.println("Failed to devide. " + e.getMessage());
        }
    }

    private static void testConstructors() {
        double doubleValue = 35.7;
        BigDecimal fromDouble = new BigDecimal(doubleValue);
        BigDecimal fromString = new BigDecimal("35.7");

        boolean decimalsEqual = fromDouble.equals(fromString);
        boolean decimalsEqual2 = fromString.equals(fromDouble);

        System.out.println("From double: " + fromDouble);
        System.out.println("decimalsEqual: " + decimalsEqual + " " + decimalsEqual2);
    }
}

它打印
From double: 35.7000000000000028421709430404007434844970703125
decimalsEqual: false false
zerosAreEqual: false false
zerosCompare: 0 0
Failed to devide. Non-terminating decimal expansion; no exact representable decimal result.

如何将BigDecimal存储到数据库中?竟然会以double值的形式存储吗?至少,如果我在没有任何高级配置的情况下使用mongoDb,它将把BigDecimal.TEN存储为1E1。
可能的解决方案是什么?
我想到一个方法-在Java中使用String将BigDecimal作为字符串存储到数据库中。您可以进行验证,例如@NotNull,@Min(10)等...然后,您可以在更新或保存时使用触发器来检查当前字符串是否是所需数字。不过,mongo没有触发器。 Mongodb触发器函数调用是否有内置方式? 我遇到了一个有趣的缺点 - {{link2:Swagger定义中的BigDecimal作为字符串}}
我需要生成swagger,这样我们的前端团队就能理解我传递给他们的数字是以字符串形式呈现的。例如,DateTime以字符串形式呈现。

我在上面的文章中读到了另一个很酷的解决方案......使用long存储精确数字。

标准的long值可以存储美国国债的当前价值(以美分而非美元计算)6477次,而不会发生溢出。更重要的是:它是整数类型,而不是浮点类型。这使得它更易于处理和准确,并且保证了行为的可靠性。


更新

https://stackoverflow.com/a/27978223/4587961

也许在未来,MongoDb会增加对BigDecimal的支持。 https://jira.mongodb.org/browse/SERVER-1393 看起来3.3.8已经完成了这个功能。

这是第二种方法的示例。使用缩放。 http://www.technology-ebay.de/the-teams/mobile-de/blog/mapping-bigdecimals-with-morphia-for-mongodb.html


3
也许提到的很多 BigDecimal 问题会消失,如果您将其与精度设置为2的 MathContext 一起使用。 - Salix alba

0
NumberFormat.getNumberInstance(java.util.Locale.US).format(num);

我喜欢这个答案的简洁性,但它会返回1.5而不是1.50,省略了最后一个零。 - JesseBoyd
尝试使用 NumberFormat.getCurrencyInstance(java.util.Locale.US).format(num); 替代。 - SedJ601

0

在javapractices.com上有一个详细的示例,说明如何实现这一点。特别是要看Money类,它旨在使货币计算比直接使用BigDecimal更简单。

这个Money类的设计旨在使表达式更自然。例如:

if ( amount.lt(hundred) ) {
 cost = amount.times(price); 
}

WEB4J工具有一个类似的类,叫做Decimal,比Money类更加精细。


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