Equals, Is, CompareTo, and the Groovy Identity Operator

If you’ve ever tried to determine if Object a is the same as Object b in Groovy, chances are you’ve thought a lot about a == b and a.equals(b) and a.is(b). To appropriately ask this question of two objects in Groovy, it’s important to understand the behavior of these three operations and the difference between the equals operator in Groovy vs Java. I’m about to walk through these behaviors, that difference, and emphasize that it’s just as important to understand a fourth operation, a.compareTo(b).

This GitHub repository will come in handy eventually: https://github.com/burkaa01/groovy-equals

Let’s start in Java

In Java equals() is used for value comparison while == is used for reference comparison. Take a look at the following example:

    String s1 = new String("s");
    String s2 = new String("s");
    s1 == s2 // false
    s1.equals(s2) // true

s1 == s2 evaluated to false because s1 and s2 did not refer to the same underlying object, while s1.equals(s2) evaluated to true because s1 and s2 were value equivalent. Let’s look at another example:

    String s3 = s1;
    s1 == s3 // true
    s1.equals(s3) // true

s1 == s3 evaluated to true this time because the two object references referred to the same underlying object.

Quick note about primitive types

It is appropriate to compare the values of primitive types using == and in cases like this the == operation in Java behaves the same as it will in Groovy:

    int i1 = 100;
    int i2 = 100;
    i1 == i2 // true

Time for some Groovy

In Groovy equals() is also used for value comparison and behaves the same as it does in Java. The == operation does not behave the same as it does in Java and instead is() is used for reference comparison. The following example is the Groovy equivalent of that first Java example:

    String s1 = new String("s")
    String s2 = new String("s")
    s1.is(s2) // false
    s1.equals(s2) // true

s1.is(s2) evaluated to false because s1 and s2 did not refer to the same underlying object, while s1.equals(s2) evaluated to true because s1 and s2 were value equivalent.

What about == in Groovy?

In Groovy == is also used for value comparison, but it is a common misconception that == behaves exactly as equals() does. Take a look at how the Groovy Language Documentation describes the identity operator:

    Identity operator
    In Groovy, using == to test equality is different from using the same operator in Java.
    In Groovy, it is calling equals.

See http://docs.groovy-lang.org/docs/latest/html/documentation/#_identity_operator

This description fails to clarify something that it should: == is not always calling equals(). Thankfully, a different portion of the Groovy Language Documentation saves the day:

    Behaviour of ==
    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.

See http://docs.groovy-lang.org/latest/html/documentation/#_behaviour_of_code_code

Let’s demonstrate that extremely important caveat:

    class BrokenString extends GString { // GString because final class String cannot be extended
        String str
        BrokenString(String str) {
            super(new String[0])
            this.str = str
        }
        String[] getStrings() { return [str] }
 
        boolean equals(BrokenString o) { // broken equals implementation
            return false
        }
    }
    BrokenString s1 = new BrokenString('s')
    BrokenString s2 = new BrokenString('s')
 
    s1 // s
    s2 // s
    s1 == s2 // true
    s1.equals(s2) // false

s1 == s2 evaluated to true while s1.equals(s2) evaluated to false. We broke the equals() implementation and == must not have called equals() because they behaved differently. This is because our BrokenString (an extension of groovy.lang.GString) implemented Comparable (or rather groovy.lang.GString implemented Comparable) and we just learned that in Groovy == translates to a.compareTo(b)==0 if they are Comparable. Let’s adjust BrokenString and prove that:

    class BrokenString extends GString { // GString because final class String cannot be extended
        String str
        BrokenString(String str) {
            super(new String[0])
            this.str = str
        }
        String[] getStrings() { return [str] }
 
        int compareTo(Object s) { // broken compareTo implementation
            return 1
        }
    }
    BrokenString s1 = new BrokenString('s')
    BrokenString s2 = new BrokenString('s')
 
    s1 // s
    s2 // s
    s1 == s2 // false
    s1.equals(s2) // true

We repaired the equals() implementation, but this time, when we broke the compareTo() implementation, we also broke ==.

Why should we care?

Now would be a good time to take a closer look at that GitHub repository: https://github.com/burkaa01/groovy-equals

Ridiculous BrokenString example aside, you should seriously consider this behavior in Groovy when you choose to implement Comparable (a behavior you do not need to consider when you implement Comparable in Java). Let’s take a closer look at the Java Documentation for the Comparable interface:

    It is strongly recommended, but not strictly required that (x.compareTo(y)==0) == (x.equals(y))

See https://docs.oracle.com/javase/8/docs/api/java/lang/Comparable.html

(x.compareTo(y)==0) == (x.equals(y)) may not be strictly required, but we just learned that in Groovy if (x.compareTo(y)==0) != (x.equals(y)) when you implement Comparable, you will effectively break ==.

Suppose we have the following Groovy class that implements Comparable:

    @EqualsAndHashCode
    class GroovyEmployee implements Comparable {
        int id
        String firstName
        String lastName
        String email
 
        @Override
        int compareTo(Object o) {
            GroovyEmployee e = (GroovyEmployee) o
            int byLast = this.lastName <=> e.lastName
            return byLast ?: this.firstName <=> e.firstName
        }
    }

And supposed we have the following GroovyEmployees defined:

    jim // Halpert, Jim, id: 440, email: jhalp1@office.com
    jimSameReference // Halpert, Jim, id: 440, email: jhalp1@office.com
    jimSameValues // Halpert, Jim, id: 440, email: jhalp1@office.com
    jimSameNameDifferentValues // Halpert, Jim, id: 123, email: jhalp2@office.com

Notice that for our GroovyEmployee we decided to only consider the firstName and the lastName of the employee in compareTo() because we only implemented Comparable so that we could sort our GroovyEmployees by last name. Look at what that does to jim == jimSameNameDifferentValues below:

    jim.is(jim) // true
    jim.is(jimSameReference) // true
    jim.is(jimSameValues) // false
    jim.is(jimSameNameDifferentValues) // false
 
    jim.equals(jim) // true
    jim.equals(jimSameReference) // true
    jim.equals(jimSameValues) // true
    jim.equals(jimSameNameDifferentValues) // false
 
    jim == jim // true
    jim == jimSameReference // true
    jim == jimSameValues // true
    jim == jimSameNameDifferentValues // true

jim == jimSameNameDifferentValues evaluated to true even though jim and jimSameNameDifferentValues do not have the same id and email. An equivalent implementation of GroovyEmployee in Java, wouldn’t have this problem:

    jim.equals(jim) // true
    jim.equals(jimSameReference) // true
    jim.equals(jimSameValues) // true
    jim.equals(jimSameNameDifferentValues) // false
 
    jim == jim // true
    jim == jimSameReference // true
    jim == jimSameValues // false
    jim == jimSameNameDifferentValues // false

What can we do?

Option 1, whenever we implement Comparable in Groovy we could consider every field value in compareTo() and always make sure (x.compareTo(y)==0) == (x.equals(y)). Option 2, if we only implemented Comparable so that we could sort our GroovyEmployees by last name, we could use a Comparator instead.

Comparator instead of Comparable

Let’s update our GroovyEmployee:

    @EqualsAndHashCode
    class GroovyEmployee {
        int id
        String firstName
        String lastName
        String email
    }

Now the Comparator:

    class GroovyEmployeeComparator implements Comparator {
        @Override
        int compare(GroovyEmployee e1, GroovyEmployee e2) {
            int byLast = e1.lastName <=> e2.lastName
            return byLast ?: e1.firstName <=> e2.firstName
        }
    }

End result: the same sort order, but now jim == jimSameNameDifferentValues will evaluate to false. Take a look:

    jim.is(jim) // true
    jim.is(jimSameReference) // true
    jim.is(jimSameValues) // false
    jim.is(jimSameNameDifferentValues) // false
 
    jim.equals(jim) // true
    jim.equals(jimSameReference) // true
    jim.equals(jimSameValues) // true
    jim.equals(jimSameNameDifferentValues) // false
 
    jim == jim // true
    jim == jimSameReference // true
    jim == jimSameValues // true
    jim == jimSameNameDifferentValues // false
Leave a Reply

Your email address will not be published. Required fields are marked *

*

*