Java 19: equals/hashCode Contract
easy⏱ 5 mincoursejava
The five rules
(1) Reflexive: x.equals(x) is true. (2) Symmetric: x.equals(y) iff y.equals(x). (3) Transitive: x==y && y==z implies x==z. (4) Consistent: repeated calls return the same result (no mutation between calls). (5) x.equals(null) is false. Plus: x.equals(y) implies x.hashCode() == y.hashCode().
The classic break: symmetry
CaseInsensitiveString.equals(String) is seductive — but breaks symmetry. A String doesn't know anything about the custom class. cis.equals(str) might be true; str.equals(cis) is false. Rule: only return true if the other object is the same class (or instanceof this class).
// BAD — breaks symmetry
public boolean equals(Object o) {
if (o instanceof String) return this.s.equalsIgnoreCase((String) o);
if (o instanceof CaseInsensitive) return this.s.equalsIgnoreCase(((CaseInsensitive) o).s);
return false;
}
hashCode must match equals
If a.equals(b), then a.hashCode() == b.hashCode(). Failing this makes the object unusable in hash-based collections: you can put then get an equal key and get null. Records auto-generate both; for classes use Objects.hash(field1, field2) or an IDE generator.
Build a contract checker
Write checkContract(a, b, c) that runs each of the 5 rules against three objects and reports pass/fail. Use a broken class (BrokenPoint — mutates state in equals) and a correct one (GoodPoint). Show the broken one failing consistency.
Prefer records or Lombok @EqualsAndHashCode
Hand-writing these is error-prone. For DTOs, use record (Java 16+). For non-record classes, use Lombok's @EqualsAndHashCode(onlyExplicitlyIncluded = true) or @Data. Always generate, never write by hand.
Quiz: Mutable hashCode
Is it OK to use a mutable field in hashCode? No. If you put the object in a HashMap then mutate the field, the bucket calculation changes — you can never find it again. hashCode must depend only on fields set at construction (or be refreshed on every mutation — expensive and brittle).