原创

BigDecimal 放进 HashSet 后,2.0 和 2.00 为什么成了两个值

2.0 和 2.00 放进 HashSet 得到两个元素,换成 TreeSet 却只剩一个。两种集合都遵守了自己的契约;真正没写清楚的是,业务究竟把什么当成相同金额。

BigDecimal a = new BigDecimal("2.0");
BigDecimal b = new BigDecimal("2.00");
System.out.println(a.equals(b));        // false
System.out.println(a.compareTo(b));     // 0
Set<BigDecimal> values = new HashSet<>();
values.add(a);
values.add(b);
System.out.println(values.size());     // 2

这个结果适用于 JDK 8 及之后版本。BigDecimal 同时保存未缩放值与 scale:前者可以理解为去掉小数点后的整数,后者决定小数位数。2.0 与 2.00 的数值相同,表示却不同。

equals() 把 scale 也算进相等条件,hashCode() 与之配套。HashSet 的去重就沿着这套规则进行。compareTo() 比的是数值大小,它在这里返回 0,因此自然排序的 TreeSet 会把两者当成同一个位置。

改用 TreeSet 可以改变现象,却也换掉了等价关系。如果一个服务返回 Set,内部实现从哈希换成有序,元素数量就悄悄变化,这比一次显式异常更难察觉。

先决定输入要不要保留精度含义

价格通常有固定的最小单位。若系统明确只接受分,可以让构造入口执行:

BigDecimal amount = input.setScale(2, RoundingMode.UNNECESSARY);

2、2.0 都能规范成两位小数;2.001 则需要舍入,UNNECESSARY 会拒绝。这个异常很有价值:它阻止代码为了放进集合而偷偷改掉金额。

若业务允许舍入,应在计价规则里指定 RoundingMode。单笔先舍入再求和,与先求和再舍入可能得到不同结果,不能让某个工具方法随意决定。对税率、测量值或用户原始报价,scale 有时还是需要保留的信息,更不适合一律固定成两位。

纯数值键可以采用另一条路线:

BigDecimal key = input.stripTrailingZeros();

stripTrailingZeros() 消除多余的十进制尾零,有助于让数值相同的输入形成同样的键。但它会改变表示,整数还可能得到负 scale,并以科学计数法展示。需要普通十进制展示时使用 toPlainString(),不要把展示习惯反过来作为集合相等规则。

只有比较,不必改对象

一次大小或金额等值判断,可以直接使用:

boolean sameAmount = a.compareTo(b) == 0;

这比先统一字符串更明确。null 应当在进入比较之前处理:它可能表示未填写、未知,也可能是非法输入,不能因为比较方法不接受 null 就顺手改为零。

难点往往发生在边界。JSON 中的 2、数据库驱动返回的 2.00、表单输入的 2.0,可能经过不同链路重新构建对象。若业务对象在这些入口都采用相同的规范化规则,HashMap、HashSet 便能正常工作;如果规则散落在每一个查询条件里,很快就会漏掉一条路径。

金额也常常需要币种参与相等关系。两美元与两人民币具有相同的 BigDecimal 数字,仍然不能合并为一个金额键。一个含“币种+规范化数额”的不可变值对象,通常比到处调用 stripTrailingZeros() 更能表达约束。

别为消除这次差异而转成 double。那是在丢掉十进制精度以后,再尝试重建原来的业务含义。

正文到此结束
评论插件初始化中...
Loading...