Groovy’s compareTo operator and Equality
Joe McCall
Posted on May 20, 2020
Let’s assume we have a Book class that looks like this:
@CompileStatic
class Book {
String name
String author
Float price
}
Within our app we have several places where a widget is presented to the end-user, such as an API that drives a mobile app, an API that drives a single-page-application (like react/vue/etc), or even something rendered server-side like a GSP.
The data is represented well, and the customer is happy, but now there’s a business rule that states that all books should be presented sorted alphabetically. We can do this several ways:
- Sort it in a controller
- Sort it in the view itself
- (Edit) Specify the sort order in the domain class
Edit: If you are using a data source that supports it, you can specify sort columns and order directly in the mapping
closure. The point of this post still stands outside the context of GORM though. If you have a list of any POGO you could be affected.
If we sort it in the controller it looks like this:
@ReadOnly
def index() {
// old code
//respond Book.list()
// new code to sort by name
respond Book.list().sort { it.name }
}
This will work, but some developers may want to record the fact that a book’s “natural order” is that of being sorted by name. In other words, the fact is stored on the domain itself of how it should be sorted. Let’s impliment this using java’s compareTo
method:
@CompileStatic
class Book implements Comparable<Book> {
String name
String author
Float price
@Override
int compareTo(Book other) {
// Sort by name ascending
return this.name <=> other.name
}
}
Now our controller method looks like this:
@ReadOnly
def index() {
// old code
//respond Book.list()
// new code to sort, this time by using the pre-defined "natural order"
respond Book.list().sort()
}
THIS HAS A VERY SERIOUS SIDE-EFFECT
The above is not an ideal solution, and it’s best illustrated with a concrete example.
def book1 = new Book(name: 'The Gadfly', author: "Ethel Voynich", price: 19.95)
def book2 = new Book(name: 'The Gadfly', author: "Johnny Copycat", price: 9.95)
assert book1 != book2 // THIS FAILS!!! GROOVY THINKS THEY ARE EQUAL!
It’s a bit worrisome that the two books are equal. This is clearly not the case.
What’s happening?
I’ll get to the technical reasons in a minute, but based on observation, it appears that it’s only checking the name
field of our class for equality, and stopping there.
At this point all we know is the language is incorrectly interpreting the idea of equality between two books. It thinks that just because the book names match, that the book objects must be equal.
Let’s try to fix this
We introduced natrual ordering, but it has clearly affected the definition of equality for books. Therefore let’s try to rectify this situation be defining an equals
method so there’s no confusion:
@CompileStatic
class Book implements Comparable<Book> {
String name
String author
Float price
@Override
int compareTo(Book other) {
// Sort by name ascending
return this.name <=> other.name
}
@Override
boolean equals(Book other) {
this.name == other.name &&
this.author == other.author &&
this.price == other.price
}
}
Now we’re expressing the idea that a book can only equal another book if the name, author, and price match.
Let’s test this:
def book1 = new Book(name: 'The Gadfly', author: "Ethel Voynich", price: 19.95)
def book2 = new Book(name: 'The Gadfly', author: "Johnny Copycat", price: 9.95)
assert !book1.equals(book2) // This passes, we should be ok
assert book1 != book2 // THIS STILL FAILS!!! WHY IS THIS?!
Why didn’t defining equals
fix ==
?
After all, doesn’t the ==
operator just delegate to the equals
method in groovy?
Let’s look closer at the documentation: http://docs.groovy-lang.org/latest/html/documentation/index.html#_behaviour_of_code_code
In Java == means equality of primitive types or identity for objects. In Groovy == translates to a.compareTo(b)==0, if they are Comparable, and a.equals(b) otherwise. To check for identity, there is is. E.g. a.is(b).
Oof. This really hinders our ability to define a natural order. Even if we define equals
, it will be ignored if our class implements Comparable
.
What is the solution?
Let’s look at the Java recommendations for the Comparable
interface: https://docs.oracle.com/javase/7/docs/api/java/lang/Comparable.html
It is strongly recommended (though not required) that natural orderings be consistent with equals. This is so because sorted sets (and sorted maps) without explicit comparators behave “strangely” when they are used with elements (or keys) whose natural ordering is inconsistent with equals. In particular, such a sorted set (or sorted map) violates the general contract for set (or map), which is defined in terms of the equals method.
In groovy, if we follow this recommendataion everything works fine. So let’s make compareTo
consistent with equals
:
@CompileStatic
class Book implements Comparable<Book> {
String name
String author
Float price
@Override
int compareTo(Book other) {
// Sort by name ascending
int cmp = this.name <=> other.name
// Then sort by author, then price
if (!cmp) {
cmp = this.author <=> other.author
}
if (!cmp) {
cmp = this.price <=> other.price
}
return cmp
}
@Override
boolean equals(Book other) {
this.name == other.name &&
this.author == other.author &&
this.price == other.price
}
}
Now when we call compareTo
on another instance it will only return 0
when the instances are equal.
def book1 = new Book(name: 'The Gadfly', author: "Ethel Voynich", price: 19.95)
def book2 = new Book(name: 'The Gadfly', author: "Johnny Copycat", price: 9.95)
assert book1.compareTo(book2) != 0 // passes
assert !book1.equals(book2) // passes
assert book1 != book2 // passes
That’s too much code. Is there a better way?
We can use Groovy’s built-in transforms to achieve the same result with far less code:
@CompileStatic
@Sortable(includes = ['name', 'author', 'price'])
@EqualsAndHashCode // Not required, but here for completeness
class Book {
String name
String author
Float price
}
The @Sortable
annotation implements the Comparable
interface for us. Furthermore, if the includes
argument is used it will check the fields in the order they are listed.
I’ve listed the @EqualsAndHashCode
transformation here as well to generate those methods, simply because we had them in the above snippets. They aren’t strictly necessary to make our test pass since Groovy will only look at compareTo
, but I think that documenting that we are defining equality somewhere in this class is important, and this is a way to achieve that.
Just a word of caution: be sure to include all fields in the @Sortable
includes list. Otherwise the natural order of the class becomes inconsistent with the equality.
What if I want to use a custom equals
method?
I can’t think of a reason you would need this, but it should be able to do something like this:
@Override
boolean equals(Book other) {
// custom definition of equality
// ...
return equalsObject(other)
}
@Override
int compareTo(Book other) {
if (this.equals(other)) {
return 0
} else {
// Normal sort logic from above:
// Sort by name ascending
int cmp = this.name <=> other.name
// Then sort by author, then price
if (!cmp) {
cmp = this.author <=> other.author
}
if (!cmp) {
cmp = this.price <=> other.price
}
return cmp
}
}
Remember, the key is to ensure that the natural order is consistent with equality.
I hope this helps someone out!
Posted on May 20, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 20, 2024
November 7, 2024