Monday, November 12, 2007

A new approach to the equals() method!

Custom equals() methods are handy but involve writing a lot of ugly boilerplate code — and there are booby-traps for the unwary. In this post, I present a way of over-riding equals() and hashCode() methods — based on annotations — which makes them a doddle to implement and maintain. The resulting code is available for download in a shrink-wrapped jar.

To recap, a == b tests whether a and b are the same object. a.equals(b) tests whether they are equivalent objects. equals() is a very useful method, and you will no doubt have found yourself over-riding the default version in several of your classes.

Over-riding equals() is not quite as straightforward as it seems. You must also override hashcode() in an equivalent manner. Otherwise HashSet, HashMap and HashTable will exhibit strange bugs. I’ve made this mistake and it’s confusing as hell to debug. Josh Bloch’s Effective Java provides more examples of how equals can go wrong.

Also equals() and hashCode() methods contain a lot of boilerplate. Here’s an example of equals() for a class — and this is for a simple class with only 2 fields:


public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
final ToyStoryState other = (ToyStoryState) obj;
if (leftBank == null) {
if (other.leftBank != null)
return false;
} else if (!leftBank.equals(other.leftBank))
return false;
if (torch == null) {
if (other.torch != null)
return false;
} else if (!torch.equals(other.torch))
return false;
return true;
}

Yuck!
Annotations to the rescue!

There is a nice solution to this issue using annotations. We’ll start with an example, then look behind the scenes at how it works.

Example



class MyObject {

@Equals
public String name;

private int id;

@Equals
public int getId() {
return id;
}

public boolean equals(Object obj) {
return Equality.equalsByAnnotation(MyObject.class, this, obj);
}

public int hashCode() {
return Equality.hashCodeByAnnotation(MyObject.class, this);
}

}

With the example above, two MyObjects are considered equal if they have the same name and id — as returned by getId(). Equals is our custom annotation, and Equality is a support class with static methods…

Making it work


We need to know which properties are important for equality. Properties can be fields or zero-argument methods, such as getters. This can be specified in a natural way by annotating fields and methods. First we define an annotation for properties that should be tested: @Equals indicates a property that should be compared using equals(). Defining an annotation is similar to defining an interface, except you use the keyword @interface, and you’ll usually want to add some meta-annotations.

@Retention(RetentionPolicy.RUNTIME) // Meta-annotation for "Don't throw this away during compilation"
@Target( { ElementType.FIELD, ElementType.METHOD }) // Meta-annotation for "Only allowed on fields and methods"
public @interface Equals {

}

Now we can annotate properties like in the example — how do we test them? We need to make use of the reflection api. Given a Class object, we can get it’s Field and Method objects via Class.getDeclaredFields/Methods(). Given those, we test for the presence of annotations using Field/Method.isAnnotationPresent(). We use a little known Java feature to get access to non-public fields — see this post for details. I put the code for all this is in a class Equality under the static methods equalsByAnnotation() and hashCodeByAnnotation() — you can download it below.

Checking the superclass


Note that the equalsByAnnotation() method does not look at properties belonging to ancestor classes. Similarly, hashcodeByAnnotation() does not incorporate properties belonging to ancestor classes. If this is necessary, the user must call super.equals() and super.hashcode(). E.g. like this:

if ( ! super.equals(obj)) return false;
return equalsByAnnotation(MyObject.class, this, obj);

and

return 17*super.hashcode() + hashcodeByAnnotation(this);

This is a deliberate design decision. If the parent class has over-ridden equals(), then it’s method (which may or may not use annotations) must be checked. I’ve left this as the user’s responsibility - partly so they keep control, partly because the code to check for an ancestor equals method would have been ugly. If you think it should be handled automatically, feel free to send me your code suggestions…

Advantages


Shorter code, cleaner code, God kills less kittens, etc. Having @Equals attached to fields and methods makes it clear what is and isn’t being tested. This helps in maintaining correct behaviour when the class is edited. And it isn’t restrictive — you can still write your own custom code if you need to.

The Disadvantage: Speed


So the code is a lot nicer — but naturally you lose a bit of speed. I did some time trials, and the annotations based method came out as 3x slower. That’s fine during development and if equals() / hashCode() are not bottlenecks. Note that equals() and hashCode() can be bottlenecks — e.g. when making intensive use of Maps. So you may not want to use this in some production systems.

0 comments: