loading...

Groovy’s compareTo operator and Equality

joemccall86 profile image Joe McCall Originally published at joemccall86.gitlab.com on ・5 min read

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

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 by:

joemccall86 profile

Joe McCall

@joemccall86

Groovy/Grails enthusiast, Kubernetes neophyte, Pursuing a master's in CS.

Discussion

markdown guide