【浮点类型计算的误差】
在财务模块的设计中,一定会涉及到金额的处理,其中字段类型的设计很关键,如果采用了float和double类型,计算结果会有误差。
float i = 1.1f; float j = 1f; System.out.println(i - j); //0.100000024
所以,在涉及到要求精度精确的金额时,一般会采用decimal类型存储在DB中,而Java计算的过程则采用BigDecimal。
【历史包袱】
在一些早期的财务软件中,或者很多初期没有考虑这方面问题的软件中,金额类的字段在数据库中也会被设置为float。如金蝶、用友等企业的早期产品往往也是用float进行金额数据的存储。但这样还怎么保证数据的准确性呢?
其实相当多的系统设计中,系统怎么去存储数据和怎么去计算数据并不统一。有的会以float存储,但是计算的时候还是会采用decimal的转换,再进行计算,虽然也能保证结果的准确性,但还是很麻烦。也就是说,存储数据用什么格式,看具体的场景,可以是int,可以是float,但是只要计算过程保证使用了decimal,就保证了计算的精度。
【为什么是decimal】
在设计实体类型时,我们用BigDecimal定义字段,数据库中以decimal存储,那么存取数据,计算过程都统一这一种类型。不必在取出来时,再进行一次类型转换,避免了很多出bug的可能;如果是Integer型或者Long存以分为单位的数据,那么存取,展示(通常是以'元'为单位)的时候都要进行单位转换,这样也很容易出错;另外,在生产过程中,通常也会有一些场景,管理人员会直接从数据库中导出数据,不经过系统拿到数据,单位是分的话是不符合财务人员的使用习惯的,这人为地增加了沟通成本。所以综合来看,decimal是一个比较好的选择,也是业界常规的做法。
【关于误差】
上面讨论了字段类型和精度的关系,这里要说明的是财务数据的误差。财务数据的计算结果有误差是避免不了的,所以财会领域有一个专业词汇叫做调账,调账就是为了调整误差带来的账目差异。
但是,我们的代码还是要保证整个计算结果的精确,这里要表达的是,就算是有误差,那么这个计算结果的误差,也应该同财务人员计算结果的误差保持一致。为了达到这个目的,我们需要保证2点:
1.在关键的,会出现误差的计算过程中,精度与财务人员的要求保持一致;
2.整个计算的步骤,也要与财务人员的计算步骤保持一致。
关于第2点,并不是说,理论上和逻辑上与这个流程一致就行,而是要在步骤上一致。这是因为步骤不同,就算逻辑处理是等价的,最终的计算结果产生的误差肯定不会一样,这就违背了刚刚说明的原则。
比如10笔订单,每笔订单收益1元,而某个代理商收益为30%。计算过程如果是先进行单笔计算,计算完汇总,保留2位小数,那么结果是3.30;但如果是先进行汇总,再进行收益计算,结果是3.33。当订单数据巨大时,这个收益的误差也会是巨大的。
当然,如果是考虑计算效率,存储效率等问题,需要对这个计算过程进行调整,只要财务人员接受由此带来的误差,也是OK的。
【Java中的BigDecimal】
基本用法:
public static void main(String[] args) { BigDecimal number1 = new BigDecimal(0.005); BigDecimal number2 = new BigDecimal(1000000); BigDecimal stringParseNumber1 = new BigDecimal("0.005"); BigDecimal stringParseNumber2 = new BigDecimal("1000000"); //加法 BigDecimal result = number1.add(number2); BigDecimal stringParsedResult = stringParseNumber1.add(stringParseNumber2); System.out.println("result=" + result); System.out.println("stringParsedResult=" + stringParsedResult); //减法 BigDecimal subtract = stringParseNumber1.subtract(stringParseNumber1); //乘法 BigDecimal multiply = stringParseNumber1.multiply(stringParseNumber2); //绝对值 BigDecimal abs = stringParseNumber1.abs(); //除法:必须制定小数后的精确位数,以及进位的原则 BigDecimal divisor = new BigDecimal("10"); BigDecimal dividend = new BigDecimal("3"); BigDecimal divideResult = divisor.divide(dividend, 3, BigDecimal.ROUND_HALF_UP); System.out.println(divideResult); }
由以上结果中,可以看到,如果构造函数中传入的是double或者float类型,那么BigDecimal的计算结果还是不准确。这里推荐使用String类型作为构造函数的参数。
对于除法,第二个参数表示计算结果小数保留位数,第三个参数表示进位的算法。这里列举所有的算法,在实际场景中可以根据需求选用:
ROUND_UP //对非舍去的部分始终在保留的最低位加1 ROUND_DOWN //与ROUND_UP相反,始终不对非舍去的部分加1
ROUND_CEILING //如果为正,则舍入原则与ROUND_UP一致,如果为负,则舍入原则与ROUND_DOWN一致 ROUND_FLOOR //与CEILING刚好相反 可以将这两种模式理解为坐标轴的方向
ROUND_HALF_UP //四舍五入 ROUND_HALF_DOWN //五舍六入 ROUND_HALF_EVEN //银行家舍入法,主要在美国使用。四舍六入是肯定的,五分为两种情况,前一位为奇数,则入位,前一位为偶数,则舍去。 ROUND_UNNECESSARY //断言使用
这里给出实际的使用场景,体会一下进位的结果:
System.out.println(new BigDecimal("0.1203456789").divide(new BigDecimal("1"),3,BigDecimal.ROUND_UP));//0.121 System.out.println(new BigDecimal("-0.1203456789").divide(new BigDecimal("1"),3,BigDecimal.ROUND_UP));//-0.121 System.out.println(new BigDecimal("0.1891").divide(new BigDecimal("1"),3,BigDecimal.ROUND_UP));//0.190 System.out.println(new BigDecimal("0.91").divide(new BigDecimal("1"),1,BigDecimal.ROUND_UP));//1.0 System.out.println(new BigDecimal("0.1203456789").divide(new BigDecimal("1"),3,BigDecimal.ROUND_DOWN));//0.120 System.out.println(new BigDecimal("-0.1203456789").divide(new BigDecimal("1"),3,BigDecimal.ROUND_DOWN));//-0.120 System.out.println(new BigDecimal("0.1891").divide(new BigDecimal("1"),3,BigDecimal.ROUND_DOWN));//0.189 System.out.println(new BigDecimal("0.91").divide(new BigDecimal("1"),1,BigDecimal.ROUND_DOWN));//0.9