Atom Feed SITE FEED   ADD TO GOOGLE READER

Implementing equals() is hard

When modeling an entity that may or may-not have an id assigned, writing the equals method is surprisingly hard. What to do when there's no id? Falling back on equals-by-value is tempting:
public class Product {
private final Id id;
private final String description;
private final Money price;

...

public boolean equals(Object other) {
if (!(other instanceof Product)) {
return false;
}

Product otherProduct = (Product) other;
if (id != null && otherProduct.id != null) {
return id.equals(otherProduct.id);
} else {
return description.equals(otherProduct.description)
&& price.equals(otherProduct.price);
}
}
}

Unfortunately, this doesn't work. The equals() method must be reflective, symmetric and transitive. For arbitrary values of a, b, and c, the following tests* must always pass:
  /* reflective */
assertTrue(a.equals(a));

/* symmetric */
assertTrue(a.equals(b) == b.equals(a));

/* transitive */
if (a.equals(b) && b.equals(c)) {
assertTrue(a.equals(c));
}

Transitivity is hard


Unfortunately, our example fails transitivity:
  Product a = new Product(5, "Large Pepperoni", Money.usd(11));
Product b = new Product(null, "Large Pepperoni", Money.usd(11));
Product c = new Product(6, "Large Pepperoni", Money.usd(11));
This is a case where a equals b and b equals c, but a doesn't equal c.

Consequences


Without a proper equals method, it's dangerous to use this Product class in Collections. For example, the behaviour of HashMap.remove(b) is poorly defined for a HashMap that contains both a and c.

The Fix


The equals method should be defined by value or by id, but not either.
I agree wholeheartedly. However, I am wondering if you could "fix" equals() by only falling back to equals-by-value if both Product objects don't have an id? Of course, this only works if you don't muck with the id field of any product contained in a collection.

if (id != null) {
return id.equals(otherProduct.id);
} else {
return otherProduct.id==null && description.equals(otherProduct.description)
&& price.equals(otherProduct.price);
}
Hey Boris! Your strategy works. But there's a small mistake in your example code that breaks symmetry.
Or maybe there's no bug - I missed the check to otherProduct.id == null in the else case.
Sometimes the frameworks you use also dictate a particular approach. You typically don't want to use IDs in equals()/hashCode() in Hibernate/JPA apps when those are generated by the DB, because the id will always be null for newly created objects that have not yet been persisted.
No. Equals must be implemented either by object identity or by the values stored in the object, but never both. If an object can change state, it must be implemented by object identity. If implemented by any state stored in the object, then the object must never change state (e.g. be a value object).
Minor correction:
the term "reflexive" was confused with "reflective."

The equals() method must be reflexive, symmetric and transitive.