I was writing some Java test code when I faced up the voracity of the
equals method. It presents, despite its ostensible simplicity, a tricky problem.I'd like to emphasize that this is not a specific Java issue. For example C# has a homologous way.
A bit of Java context
The
Object class is the root of every class. It defines various methods and equals is one of them. By default this method has a simple behavior: an object x is only equals to itself. Any other object is different.Obviously the
equals method has the common properties of an equivalence relation. It is reflexive: x is equal to x; It is symmetric: if x is equal to y, then y is equal to x; and so on...Furthermore a logic relationship links
equals and the hashCode method. The latter returns the object hash. In this context it's an integer representation of the object. So if two object are equal, then their hash should be equal too.The problem
I'll use a simple code example to highlight the main issue. Here is the starting point:
interface Book {
String title();
String author();
}
final class DbBook implements Book {
...
}We can ask to a
Book instance its title and its author. A DbBook instance represents a book stored in a database.As I said the
Object class is the root of every class. This means that DbBook also inherits the equals method with its default behavior. So we should override equals to implement a custom equivalence relation. To respect the aforesaid logic implicatio we should also override the hashCode method.Now suppose that in our context two books are equal if they have the same title. This seems the
equals goal and here is a common implementation (I generated it with the IDE):final class DbBook implements Book {
...
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (!(o instanceof DbBook)) return false;
final DbBook dbBook = (DbBook) o;
return title().equals(dbBook.title());
}
@Override
public int hashCode() {
return Objects.hash(title());
}
...
}However because the
instanceof check, aDbBook is only comparable to anotherDbBook. This means that an AnotherBookImplementation instance is always different from aDbBook, though having the same title.The problem seems the
instanceof check. So we can weak it a bit:final class DbBook implements Book {
...
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (!(o instanceof Book)) return false;
final Book book = (Book) o;
return title().equals(book.title());
}
...
}In this way we are restricting
o to be a Book instance.The effect of this change is the destruction of our software. As I anticipated the
equals method should be reflexive. This means that every Book implementation must exhibit this equals behavior. In other words: every Book implementation is high-coupled with each other. Definitely it's a really bad approach.A different approach
The main issue of the
equals approach is that responsabilities aren't decoupled correctly. There should be another object responsible of the comparison. There should be another object that represents the comparison.There may be various implementation of this approach. I'll suggest one called representation-based and another behavior-based.
Representation-based equality
The first one is derives from this article. Basically an object (like
aDbBook) can gives us a representation of itself. Then a Comparison<R> object represents a comparison between two R representation. In this way a representation is similar to the hash returned by hashCode. But it's more generic because it could be based on bytes, strings and so on...However this means that
aCat could be equal to aDog, if they have the same representation. I consider this as the main drawback of this approach.Behavior-based equality
The behavior-based is born from an observation. I think that the only valid discriminating factor about objects is their behavior. It's exposed through the methods. Or, more formally, through the messages the object supports. The protocol or interface is the collection of the supported messages.
For this reason the first step to define equality should be based on interfaces. Then an
Equality object will represent the equality between two objects with the same interface.In this way
aCat will be always different from aDog because the different interfaces. Presumably the former implements a Cat interface, the latter a Dog interface. Nonetheless, thanks to polymorphism, if they both implement a Pet interface, then they could be equal. This could be possibile with anEquality limited to the Pet interface.Here is an example related to the initial
Book example. I defined two Equality classes to stress out that equality is not a Book responsability.interface Equality {
Boolean equals();
}
final class TitleBasedEquality implements Equality {
TitleBasedEquality(final Book book, final Book anotherBook) {
this.book = book;
this.anotherBook = anotherBook;
}
@Override
public Boolean equals() {
return book.title().equals(anotherBook.title());
}
private final Book book;
private final Book anotherBook;
}
final class PrefixBasedEquality implements Equality {
PrefixBasedEquality(final Book book, final Book anotherBook, final Integer length) {
this.book = book;
this.anotherBook = anotherBook;
this.length = length;
}
@Override
public Boolean equals() {
var first = book.title().substring(length);
var second = anotherBook.title().substring(length);
return first.equals(second);
}
private final Book book;
private final Book anotherBook;
private final Integer length;
}So a
TitleBasedEquality object compares the full title. A PrefixBasedEquality compares only a prefix.We gather a lot of flexibility. And we can choose the correct equality comparison based on the context. This is possible thanks to the responsability decoupling.
However, as you can see, I'm using
String.equals. I could replace it with StringEquality. But I consider this case as a reasonable compromise forced by the programming language.A possible drawback of this approach regards an interface with only void methods. In this case each pair of instances are always equal. But this means that these type of objects are only and always comparated on their interface. I find it coherent and I find it respectful towards the objects.
Conclusion
Definitely the object equality problem is a tough problem. I think that the major issue is that we think equality in terms of data. But objects are not data. This is the reason because I support the idea of some sort of behavior-based comparison. After all the exhibited behavior is what distinguishes one object from another one. Nothing more.