Maintaining equals and compareTo contract

Any change in the collection being used should not impact the behavior of a class.

As a java developer, you must have been already following a famous principle to maintain the contract between the hashCode and equals methods.

But we often ignore another equally important principle: to maintain the contract between equals and compareTo methods.

Consider the following example and try to identify the output ( JDK-6394757):

public static void main(String[] args) {
    test("A");
    test("A", "C");
}

private static void test(String... args) {
    var caseInsensitiveSet = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
    caseInsensitiveSet.addAll(List.of("a", "b"));
    caseInsensitiveSet.removeAll(List.of(args));
    System.out.println(caseInsensitiveSet);
}

The sample code, when tested on openjdk@1.16.0 produces the unexpected output:

[b]
[a, b]

This is the result of an implementation choice targeted to improve the iteration performance, but unfortunately, results in unexpected output. The removeAll method, depending on the size of this set and the argument size, will switch from:

  • iterating this collection and calling contains on its argument \(to\)
  • iterating the argument and calling this.contains

As mentioned here:

This attempt at optimization is incorrect because this collection and the argument collection might have different semantics for contains().

The contains method of TreeSet is essentially using the specified comparator to check the equality, but the list is using the equals.

The TreeSet being a Set, should be compliant to the Set interface contract where all the elements are termed equal based on the equals method. In contrast, the SortedSet(up in the TreeSet hierarchy)introduces another behavior of equality to TreeSet based on the compareTo or compare method, which is not in sync with the equal method of the List class of the arguments.

Another scenario where the contract is violated is the BigDecimal class. Consider the following example:

var someBigDecimalNumber = new BigDecimal("0.20");
var hashSet = new HashSet<BigDecimal>();
var treeSet = new TreeSet<BigDecimal>();

hashSet.add(someBigDecimalNumber);
treeSet.add(someBigDecimalNumber);

System.out.println(hashSet.contains(new BigDecimal("0.2"))); // false
System.out.println(treeSet.contains(new BigDecimal("0.2"))); // true

The compareTo and equals method in the BigDecimal class does not maintain the contract: (x.compareTo(y)==0) \(\Rightarrow\) (x.equals(y)).

The TreeSet using the compareTo method from the BigDecimal class to perform the contains check correctly identifies that the two BigDecimal values are equal and thus prints true. On the other hand, the HashSet working on just the hashCode and equals methods from the BigDecimal class to perform the contains check fails to validate the equality and prints false in-correctly.

Due to this, the collections that work only on equality (or hashCode) treat the two BigDecimals as different numbers. Whereas, the collections using the compareTo method to perform the comparison will treat the two BigDecimals as equal which might produce unexpected results.

Any change in the collection being used should not impact the behavior of a class.

In other words, two instances, if deemed equal in one type of collection, should evaluate to equal when stored in different kinds of collections as well. Hence it is suggested that we should always maintain the contract between equals and compareTo methods (if applicable).

That is all for this post. If you want to share any feedback, please drop me an email, or you can contact me on any of the social platforms. I’ll try to respond at the earliest. Also, please consider subscribing for regular updates.

Be notified of new posts. Subscribe to the RSS feed.