Blog

Think twice before overriding Object.equals()

By  
Matti Tahvonen
Matti Tahvonen
·
On May 8, 2024 10:00:00 AM
·

Overriding the Object.equals(Object) method, and its “companion” Object.hashCode(), provides your classes with some superpowers, but they are also one of the most common sources of problems that I see new and sometimes also more experienced Java and Vaadin developers struggling with. Some developers routinely override these, often for the wrong reasons; others do it accidentally via code generation tooling. I like to use Project Lombok, but beware of its @Data annotation!

In case you slept through your “Algorithms and data structures” course, or just too much time has passed since then, read this article before overriding the equals() method.

You might not need the equals() method

The equals() method is already present in the root of all objects, java.lang.Object. It can be handy in your own application code, but the reason is the powerful Collections Framework that ships with the JDK. Without the equals() method, collections couldn’t work logically with different instances of objects that are essentially the same.

A simple example with strings:

    String bar = "bar";
    String alsoBar = "bar";
    // Compiler warning, but this is true, due to compiler optimizations
    System.out.println("(bar == alsoBar): " +(bar == alsoBar));
    // Now alsoBar becomes a truly different instance
    alsoBar = alsoBar + "";
    // false although alsoBar is still "bar"
    System.out.println("(bar == alsoBar): " +(bar == alsoBar));
    // but this is true
    System.out.println("bar.equals(alsoBar): " + bar.equals(alsoBar));

Java doesn’t have operator overloading, so comparing strings with “==” is not the way to go, either for you or for collection implementations. Thus the equals() method is the only proper way to check the equality of two different strings.

Often, the default implementation of the equals() method (comparing the references) is just fine for your application. But sometimes you need to compare your objects in your own application code or make them work “logically” in collections.

If the requirement is only the former, my advice is to find another way than using the equals() method, if possible. Using a static helper method to compare your instances, or just another method name, allows you to forget the tricky API contracts related to the equals() and hashCode() methods. And your classes still work fine in collections like HasSet or HashMap.

Only if you need different instances to be considered the same in JDK collections should you override the equals() and hashCode() methods. This could be the case, for example, if you have “detached entities” representing the same logical information. For example, with JPA you might have two “snapshots” of the same database row that you might want to be considered equal. 

Mutable objects and Lombok’s @Data annotation

The situation where I have seen most people struggling with overridden equals()/hashCode() implementation is with mutable objects. And in a large proportion of these cases, it’s because the developer didn’t even realize that they had overridden them. Check out the following code example using the @Data annotation from Project Lombok, where something very unexpected happens:

public class Examples {

    @Data // Generates getters, setters, equals and hashcode methods
    @AllArgsConstructor // constructor with name value
    public static class Foo {
       private String name;
    }

    public static void main(String[] args) {
        Set setOfFoos = new HashSet<>();
        Foo foo = new Foo("Foo ");
        setOfFoos.add(foo);
        if (setOfFoos.contains(foo)) {
            // But of course it does, we just added there
            // Let's change the object a bit...
            foo.setName(foo.getName() + " (modified)");
            boolean itIsStillThere = setOfFoos.toArray()[0] == foo;
            if (itIsStillThere) {
                // But of course it is, we never removed it and it equals() too
                 if(!setOfFoos.iterator().next().equals(foo)) {
                     // this should never happen
                     System.exit(1);
                 }
                
                // Now the surprise, if you ask the set directly...
                if (!setOfFoos.contains(foo)) {
                    throw new RuntimeException("WTF!?");
                }
            }
        }
    }
}

To investigate the issue more, let’s “de-lombok” the generated methods:

@java.lang.Override
public boolean equals(final java.lang.Object o) {
    if (o == this) return true;
    if (!(o instanceof Examples.Foo)) return false;
    final Examples.Foo other = (Examples.Foo) o;
    if (!other.canEqual((java.lang.Object) this)) return false;
    final java.lang.Object this$name = this.getName();
    final java.lang.Object other$name = other.getName();
    if (this$name == null ? other$name != null : !this$name.equals(other$name)) return false;
    return true;
}

protected boolean canEqual(final java.lang.Object other) {
    return other instanceof Examples.Foo;
}

@java.lang.Override
public int hashCode() {
    final int PRIME = 59;
    int result = 1;
    final java.lang.Object $name = this.getName();
    result = result * PRIME + ($name == null ? 43 : $name.hashCode());
    return result;
}

The implementation looks quite “pro” at first glance, even using some prime numbers for optimal hashing. The problem arises from the non-stable hashCode() implementation. HashSet and HasMap use so-called “buckets” for optimal efficiency of methods like contains(). These collections assume that the hashCode returned by the stored object never changes. After the name field is changed in the code example, the contains() method checks the wrong bucket!

Doing it right

The right way to do it would be to only use the @Getter and @Setter annotations from Lombok. If you need to compare the equality of two different instances, compare their individual properties. If you really need to consider certain objects the same in collections, you need to write proper equals() and hashCode() implementations manually. Alternatively, you could use @EqualsAndHashCode(onlyExplicitlyIncluded = true) and then manually annotate only fields like “id” to be included in the implementation.

A good rule of thumb for the implementation, with or without Project Lombok, is to only use immutable fields in equals() and hashCode() implementations! Otherwise, sooner or later you will find bugs that are nasty to debug in your application. If not directly in your own code, for example Vaadin components like Grid and Select, and dozens of other Java libraries, utilize hashing JDK collections in their implementations.

Matti Tahvonen
Matti Tahvonen
Matti Tahvonen has a long history in Vaadin R&D: developing the core framework from the dark ages of pure JS client side to the GWT era and creating number of official and unofficial Vaadin add-ons. His current responsibility is to keep you up to date with latest and greatest Vaadin related technologies. You can follow him on Twitter – @MattiTahvonen
Other posts by Matti Tahvonen