为什么BigDecimal.equals方法要分别比较值和小数位?

54
这不是关于如何比较两个BigDecimal对象的问题 - 我知道你可以使用compareTo代替equals来做到这一点,因为equals被记录为:

与compareTo不同,此方法仅在值和规模相等时才将两个BigDecimal对象视为相等(因此,当使用此方法进行比较时,2.0与2.00不相等)。

问题是:为什么要以这种看似违反直觉的方式指定equals?也就是说,为什么能够区分2.0和2.00是重要的

看起来很可能有一个原因,因为指定compareTo方法的Comparable文档说明:

强烈建议(但不是必需的)自然排序与equals一致

我想必须有一个忽略此建议的好理由。


9
值得注意的是,new BigDecimal("2.0").compareTo(new BigDecimal("2.00")) == 0 - Peter Lawrey
7个回答

40

有些情况下,精确度指示(即误差范围)可能很重要。

例如,如果您正在存储由两个物理传感器测量的数据,其中一个的精度比另一个高10倍。表示这一事实可能非常重要。


37
根据我的经验,你希望使用equals()来捕捉语义精度的情况比直观情况少得多。此外,直观情况意味着BigDecimalcompareTo()equals()一致。在我看来,Sun 公司在这里犯了一个错误。 - bowmore
4
@bowmore,我也这么猜,但是经验因人而异。纯粹主义者可能会认为他们应该提供两个类——一个类不适合排序(没有“compareTo”),它将精度作为对象可见部分;第二个类实现了“Comparable”,带有与“equals”一致的“compareTo”,将比例和价值视为整体。然而,提供两者可能会显得相当臃肿/不实用,并且会创建混乱而不是缓解混乱——Sun通过提供不一致的“compareTo”和“equals”来允许两种功能(并使我们中的许多人感到惊讶)。 - bacar
13
@bacar 如果实现一个类似于 boolean equalsWithPrecision(BigDecimal other) 的方法,将允许同时具备这两个功能,并且保持一致性。 - bowmore
4
它似乎也会破坏Set和Map的使用方式。 - Geoffrey De Smet
3
这句话的意思是,“这样使用是否‘有问题’取决于集合的预期目的。如果要创建一个集合,允许将引用等效但不同的实例替换为对单个实例的引用,则‘equals’的行为是完美的;我认为与使用方式不一致的‘equals’定义有些危险。” - supercat
显示剩余2条评论

34
equals的一般规则是,两个相等的值应该可以互相替换。也就是说,如果使用一个值进行计算得到某个结果,将一个equals值替换为相同的计算应该给出与第一个结果相等的结果。这适用于像StringIntegerBigDecimal等作为值的对象。

现在考虑BigDecimal值2.0和2.00。我们知道它们在数值上是相等的,并且对它们执行compareTo将返回0。但equals返回false。为什么?

下面是一个不可互相替换的例子:

var a = new BigDecimal("2.0");
var b = new BigDecimal("2.00");
var three = new BigDecimal(3);

a.divide(three, RoundingMode.HALF_UP)
==> 0.7

b.divide(three, RoundingMode.HALF_UP)
==> 0.67

结果明显不相等,所以a的值不能替换b。因此a.equals(b)应该为false。


1
你用这个例子让它听起来如此简单。太棒了! - Eugene
11
这个例子非常棒,我们决定将其放入Javadoc文档中:https://github.com/openjdk/jdk/commit/a1181852 (它应该会出现在JDK 17构建版本13中)。 - Stuart Marks
1
这就导致我们得出结论,在混合顺序和相等性时应该小心,否则,我们会得到像 Stream.of("0.1", "0.10", "0.1") .map(BigDecimal::new) .sorted().distinct() .forEach(System.out::println); 这样的错误。 - Holger
2
@Holger 正确。JDK-8223933 - Stuart Marks

10

其他回答中尚未考虑的一点是,equals 必须与 hashCode 保持一致,而为了让 hashCode 在 123.0 和 123.00 上产生相同的值(但仍能区分不同的值),所需实现的成本将比不需要这样做的 hashCode 实现大得多。根据当前的语义,hashCode 对于每个32位存储值需要进行乘以31和加法运算。如果要求 hashCode 在不同精度的值之间保持一致,它要么必须计算任何值的归一化形式(代价很高),要么至少要像基于精度计算值的基数-999999999数字根并对其进行乘法模运算。该方法的内部循环如下:

temp = (temp + (mag[i] & LONG_MASK) * scale_factor[i]) % 999999999;

通过使用64位模操作取代乘以31的方式——这种方式更加耗费资源。如果想要一个哈希表将数值上等价的BigDecimal值视为相等,并且大多数被查找的键都能在表中找到,那么实现所需结果的高效方式是使用存储值包装器的哈希表,而不是直接存储值。要在表中查找值,请首先查找该值本身。如果没有找到,则规范化该值并查找它。如果仍然没有找到,则创建一个空的包装器,并在数字的原始和规范化形式下存储条目。

查找尚未在表中出现过且以前也没有被搜索过的内容需要进行昂贵的规范化步骤,但查找已经搜索过的内容则会快得多。相比之下,如果HashCode需要返回完全不同的数值的等效值,因为其精度不同而被完全不同地存储,那么所有哈希表操作都会变得更慢。


有趣的观察。正确性胜于性能,因此您必须先列出BigDecimal类的“正确”行为的短列表(例如是否应将比例/精度视为相等),然后再考虑性能。 我们不知道这个特定的参数是否扭转了局面。 当然,您的论点同样适用于equals方法。 - bacar
@bacar:对于任何对象,可以合理地提出两个等价相关的问题(在我看来,“Object”的虚拟方法应该提供这两个问题的解决方案):“即使引用被自由共享给外部代码,X和Y是否可以安全地视为等价?”以及“如果所有组成部分的可变状态都由其所有者独占控制,X和Y是否可以被其所有者安全地视为等价?” 我建议,唯一应该以不符合上述任何一种方式定义“equals”的类型是那些不希望其实例被... - supercat
例如,如果需要使用一组哈希字符串,这些字符串以不区分大小写的方式进行比较,则可以定义一个CaseInsensitiveStringWrapper类型,其equalshashCode操作于包装字符串的大写版本。尽管包装器对于equals具有“不寻常”的含义,但它不会暴露给外部代码。由于BigDecimal旨在供外部代码使用,因此仅当所有合理的外部代码都认为它们是等效的时,它才应将实例报告为相等。 - supercat
@bacar:就我个人而言,我认为BigDecimalequalscompareTo方法的情况非常好:希望根据值进行比较的代码可以使用compareTo,而希望基于等价性进行比较的代码可以使用equals。请注意,精确度不仅影响输出;我认为至少有一种除法方式使用被除数的精度来控制结果舍入的精度,以便 10.0/3 会是 3.3,而 10.000/3 将产生 3.333。因此,将 10.0 替换为 10.000 是不安全的。 - supercat
1
如果相等性规定不同,那么可能已经指定了除法的不同行为。我认为你的CaseInsensitiveStringWrapper提出了一个非常有趣的观点 - 在更严格的基础上实现“模糊”的等价是很容易的,而在更模糊的基础上实现严格的等价可能会更难、不可能或者仅仅是令人惊讶。无论哪种方式,最少惊讶原则都会对一组用户或另一组用户造成违反。 - bacar
@bacar:我建议如果用户被教导,当他们想要测试宽松相等性时,应该期望使用除equals之外的其他方法,那么就不会有人感到惊讶。 - supercat

6
在数学中,10.0等于10.00。在物理学中,10.0m和10.00m有不同的精度,因此可以认为它们是不同的。当谈论面向对象编程中的对象时,我肯定会说它们是不相等的。如果equals方法忽略了精度,很容易想到意外的功能。(例如:如果a.equals(b),那么你是否期望a.add(0.1).equals(b.add(0.1))呢?)

2
是的,我会期望那样,但我不明白你的观点;我并不建议忽略比例尺;我建议将价值和比例尺作为一个整体来考虑,就像compareTo一样。 - bacar
6
好的。我明白有时用户可能想要考虑精度,但我仍然不理解你关于意外功能的观点。如果他们选择让2.0等于2.00,我不确定你提到加0.1会引起问题的例子在哪里。 - bacar

5
如果数字被舍入,它显示了计算的精度 - 换句话说:
  • 10.0 可能意味着确切的数字在 9.95 和 10.05 之间
  • 10.00 可能意味着确切的数字在 9.995 和 10.005 之间
换句话说,它与 算术精度 相关。

2
compareTo 方法知道尾随零不会影响 BigDecimal 表示的数值,这是 compareTo 关心的唯一方面。相比之下,equals 方法通常无法知道对象的哪些方面是程序员关心的,因此只有在两个对象在程序员可能感兴趣的每个方面都相等时才应返回 true。如果 x.equals(y) 为真,则 x.toString().equals(y.toString()) 得到假是非常令人惊讶的。
另一个更重要的问题是,BigDecimal 本质上将 BigInteger 和缩放因子结合起来,因此,如果两个数字表示相同的值但具有不同数量的尾随零,则一个数字将保持其值为另一个数字的十的某个幂次方倍的 bigInteger。如果相等需要匹配尾数和比例,则 BigDecimalhashCode() 可以使用 BigInteger 的哈希码。然而,如果两个值即使包含不同的 BigInteger 值也被认为是“相等”的,则会显著复杂化。一个使用自己的后备存储而不是 BigIntegerBigDecimal 类型可以以各种方式实现,以允许快速哈希数字,使表示相同数字的值比较相等(例如,一种将九个十进制数字打包到每个 long 值中并始终要求小数点位于九个数字组之间的版本,可以计算出在忽略值为零的尾随组的情况下忽略尾随组的哈希码),但是封装 BigIntegerBigDecimal 无法这样做。

2
“equals”方法通常无法知道某人关心对象的哪些方面,我强烈反对这种说法。类定义(有时是隐式的)其外部可见行为的契约,其中包括“equals”。类经常存在特别是为了隐藏(通过封装)用户不关心的细节 - bacar
2
此外 - 我认为通常情况下你不应该期望equalstoString一致。类可以自由地定义toString。考虑JDK中的一个例子,Set<String> s1 = new LinkedHashSet<String>(); s1.add("foo"); s1.add("bar"); Set<String> s2 = new LinkedHashSet<String>(); s2.add("bar"); s2.add("foo"); s1s2具有不同的字符串表示,但是它们是相等的。 - bacar
我也不同意这个说法。在我的经验中,当覆盖自定义对象中的equals()方法时,最好是在小范围内定义等价关系(即尽可能少的对象属性),而不是在大范围内。贡献于等价性的属性越少,越好。数据库也遵循这个原则。 - ryvantage
对于我来说,在我的应用程序中,我使用与数据库完全相同的对象建模,使用HashSet存储它们,使用add()contains()等方法查找等同项。因此,起初,当我重写equals()时,它会比较对象的每个字段,但如果由于某种原因添加了一个略有不同的新元素,则HashSet将保留它们两个,这是不好的。最终,我定义了基于数据库的id(主键)的唯一性(和哈希值)。 - ryvantage
这些对象是可变的还是不可变的,它们与任何持久存储的关系是什么?如果这些对象与数据库中的行相关联,我建议同一行附加多个不同的对象在第一时间就不应该存在。否则,我不太清楚为什么您要使用“Set”而不是“Map”?我认为自然的存储方式应该是作为一个“Map”,其“key”对象封装了与相等性相关的数据部分。 - supercat
显示剩余3条评论

2
我想忽略这个建议肯定有充分的理由。也许没有。我提出一个简单的解释,即BigDecimal的设计者只是做出了错误的设计选择。
好的设计会优化常见用例。大多数情况下(>95%),人们希望根据数学相等性比较两个数量。对于少数真正关心两个数字在比例和价值上是否相等的情况,可以添加一个额外的方法来实现此目的。
它违反了人们的期望,并且很容易陷入陷阱。良好的API遵循“最小惊讶原则”。
它打破了通常Java约定Comparable与相等一致的约定。
有趣的是,Scala的BigDecimal类(在幕后使用Java的BigDecimal实现)已经做出了相反的选择:
BigDecimal("2.0") == BigDecimal("2.00")     // true

1
equals 的一个基本要求是,具有不相等哈希码的两个对象必须比较不相等,而 BigDecimal 的设计使得具有不同精度的数字被存储得非常不同。因此,让 equals 将具有不同精度的值视为等价将极大地损害哈希表的性能,即使其中所有值都以等价精度存储。 - supercat
1
@supercat 很好的观察。然而,我认为以 BigDecimal 为键的 Map(和 Set)是如此罕见的用例,以至于这并不足以证明需要一个与比例相关的 equals - Matt R
使用像 map 键这样的类型可能并不是非常普遍,但也不是非常罕见。除此之外,最终计算类似值的代码有时可能会从缓存频繁计算的值中获得巨大的好处。为了使其高效工作,哈希函数必须既好又快。 - supercat
2
  1. 可以肯定的是,BigDecimal 键比人们被其不直观的相等定义所困扰要少得多;
  2. 如果一个不考虑小数位的哈希函数成为性能瓶颈,那么你很可能处于使用 BigDecimal 本身太慢的情况下(例如,你可以在货币计算中切换到 long)。
- Matt R

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