金融计算应该避免使用浮点数? Posted by xmpace on 2020-03-14

https://www.zhihu.com/question/65960911

知乎上的一个问题引起了大家的讨论

以前我也认为涉及到钱的地方不应该用浮点数,看了一圈答案后发现其实是我根本没想明白问题出在哪,这里就来总结下。

先说说那个最常见的问题:

System.out.println(2.0-1.1);

付 2 块钱找零 1 块 1,结果是 0.899999999 而不是 0.9。

原因是 1.1 这个十进制数无法用 64 位的二进制数来精确表示,1.1 在计算机中的二进制表示为 11111111110001100110011001100110011001100110011001100110011010,而这个二进制转为十进制表示时,其值为 1.100000000000000088817841970012523233890533447265625,略大于 1.1,于是就出现了上面的结果。

那么 System.out.println(1.1); 结果是多少呢?结果还是 1.1 。WHY?不是应该打印 1.100000000000000088817841970012523233890533447265625 吗?

这是因为 System.out.println(1.1) 只能打印到小数点后 16 位,所以是 1.1000000000000000,后面的 0 省略掉就是 1.1 了。

这个例子往往被 Java 程序员用来说明金融计算不能用浮点数。

不能用浮点数那怎么办呢?有人提出钱一律存成分,这样钱可以用 int 或 long 来表示,然后做计算就都是整数计算了。这明显不对,整数加减乘都没问题,但除的话结果有可能是小数,因此这个方法行不通。

于是有人建议一律用十进制大数做计算,比如 Java 中的 BigDecimal。

BigDecimal a = new BigDecimal("2.0");
BigDecimal b = new BigDecimal("1.1");
System.out.println(a.subtract(b)); // 0.9

用 BigDecimal 真的就解决问题了吗?其实未必。

比如这样一个场景,有家放贷公司,年利息是 7%,但它允许你按天借,如果你借 21400 元,73 天后就还,那么利息就是 21400 * 0.07 / 365 * 73,如果我们用手算公式,那这个利息其实是一个准确值,为 299.6 元,因为 73 和 365 可以约分,但银行里算这种利息都是分步计算的,用代码写这个计算过程如下:

BigDecimal loan = new BigDecimal("21400");
BigDecimal interestRate = new BigDecimal("0.07");
BigDecimal interest = loan.multiply(interestRate);
interest = interest.divide(new BigDecimal("365"), 90, RoundingMode.HALF_UP); // 这行有精度问题,为了说明问题,这里精度先精确到小数点后 90 位
interest = interest.multiply(new BigDecimal("73"));

上面倒数第二行其实存在精度问题,因为结果算出来应该是 4.10410958904109589041095890410...,这是一个无限循环小数,这说明,在金融计算过程中也会出现用 BigDecimal 无法精确表示的数,那这种情况怎么办呢?

其实,在金融领域,这种计算都是有严格准确的计算规则的,上面这个例子,在银行计算时,除以 365 这一步,得出的结果需要马上做四舍五入,得出一个精确到分的日利息额,然后再去乘以实际借款天数得到最终的利息额。

因此,正确的计算是:

BigDecimal loan = new BigDecimal("21400");
BigDecimal interestRate = new BigDecimal("0.07");
BigDecimal interest = loan.multiply(interestRate);
interest = interest.divide(new BigDecimal("365"), 2, RoundingMode.HALF_UP); // 这一步做四舍五入,精确到分
interest = interest.multiply(new BigDecimal("73")); // 299.3 元

由上面的例子可以看出,即使用 BigDecimal,也会出现中间步骤有 BigDecimal 无法精确表示的数,造成精度损失。

其实金融计算中的精度损失根本就无法避免,银行里职员用计算器算也会碰到这种情况,毕竟计算器的屏幕位数也是有限的。

问题解决的关键其实在于,金融计算是有明确规定的,比如上面算日利息那步就要做四舍五入。那具体哪些步骤做四舍五入呢?这得看银行规定,基本上,有可能算出多于两位小数精度的步骤都需要做四舍五入。

有了上面的规则,我们会发现,其实金融计算用 double 也是完全 OK 的,限定在金融领域,double 的精度(15到16位有效数字)完全够用了。

我们再看开头的例子:

System.out.println(2.0-1.1); // 0.899999999 没做四舍五入

会发现我们其实用错了,这一步应该四舍五入到两位小数,这样的话,结果就是正确的了。

System.out.println((double)Math.round((2.0d-1.1d)*100)/100); // 0.9 四舍五入的老办法,精确到小数点后两位

最后总结下,Double 相比大数和整数的确有更多的精度问题,如对阶,累积误差。但放在金融计算领域,用 Double 做计算是完全可以的,Java 中算钱并不是非用 BigDecimal 不可。尽管如此,钱的存储或者通过 API 的给出都不应该再用浮点数,因为钱最终是多少必须是百分百精确的,所以一般我们会选择将钱以分为单位做整数存储。