When equals/hashCode are inherited across a class hierarchy, how can a subclass adding a field break the hashCode contract, and how do you avoid it?
answer
- Add a value field via subclass -> symmetry or LSP breaks
- instanceof breaks symmetry; getClass breaks Liskov
- No perfect inheritance solution for value classes
- Fix: composition over inheritance (hold a Point field)
- Records/final value classes dodge it entirely
basics
~20 sIf a subclass adds a field and changes equals/hashCode to use it, a parent object and a child object can end up 'equal' one way but with different hashCodes, breaking the rule that equal objects share a code. Avoid it by using composition instead of extending value classes, or by comparing exact classes.
solid answer
~50 sThe hazard appears when an instantiable parent class defines value-based equals/hashCode and a subclass adds a significant field. If the subclass's equals uses `instanceof` against the parent type, you can get asymmetry: a parent considers a child equal (it only checks parent fields) while the child considers the parent unequal (the extra field differs) — and their hashCodes differ, violating the equal-objects-share-a-code rule once the relation is reconciled. Using `getClass()` instead of `instanceof` restores symmetry but breaks Liskov substitution: a subclass instance is never equal to a parent instance even when it should be substitutable. There is no fully satisfying way to add a value field to an instantiable class via inheritance. The standard resolution from Effective Java is **favor composition over inheritance**: give the would-be subclass a private field of the parent type and a view accessor, instead of extending. Alternatively keep the value class effectively final, or restrict equality/hashCode to an immutable, abstract-class-rooted hierarchy where no instantiable level adds equals-relevant state.
code
java · 15 lines// PROBLEM: extending an instantiable value class
class Point {
final int x, y;
Point(int x, int y){ this.x=x; this.y=y; }
@Override public boolean equals(Object o){
return o instanceof Point p && p.x==x && p.y==y;
}
@Override public int hashCode(){ return java.util.Objects.hash(x,y); }
}
// colorPoint.equals(point) vs point.equals(colorPoint) -> asymmetric, codes differ
// FIX: composition — ColorPoint HAS-A Point, no inheritance
record ColorPoint(Point point, String color) { // record: final + correct equals/hashCode
Point asPoint(){ return point; }
}go deeper
Aware that subclassing a class with custom equals/hashCode can cause surprising bugs and that records/final classes are safer for keys.
Can show the asymmetry from instanceof-based equals and knows that equal objects must share a hashCode, so the break propagates to hashCode too.
Explains the instanceof-vs-getClass trade-off (symmetry vs Liskov), and applies composition-over-inheritance or final/record value classes as the fix.
Frames it as an inherent limitation of inheritance for value types, sets team policy (records/final keys, getClass only for sealed/final), and connects the failure to concrete HashSet membership anomalies in production.
## The core tension `equals` and `hashCode` are *paired*: equal objects must share a hashCode. Inheritance complicates this whenever an **instantiable** class has value-based equals/hashCode and a subclass introduces a new field that *should* count toward equality. You then must decide how a parent instance and a child instance relate — and every choice has a flaw. ## Setup ``` class Point { int x, y; // value-based equals/hashCode on (x, y) } class ColorPoint extends Point { Color c; // wants equality on (x, y, c) } ``` ### Option A: subclass equals uses `instanceof Point` and checks only x,y when comparing to a plain Point This tries to keep `Point` and `ColorPoint` comparable, but breaks **symmetry**: `point.equals(colorPoint)` may be `true` (Point checks only x,y) while `colorPoint.equals(point)` is `false` (ColorPoint also wants the color to match, but a Point has none). `equals` *must* be symmetric, so this is already a contract violation — and because the two objects are 'equal' in one direction yet have **different hashCodes** (ColorPoint folds `c` in), the equal-implies-equal-hash rule is also jeopardized. Worse, mixing such objects in a `HashSet` produces inconsistent, order-dependent membership results. ### Option B: every equals uses `getClass()` (exact-class check) Now `point.equals(colorPoint)` is `false` both ways (different classes) — symmetry and the hashCode link are restored. **But** this violates the **Liskov Substitution Principle**: a `ColorPoint` can no longer stand in for a `Point` in equality-sensitive code, even though it *is* a Point. If some library treats all Points equal by location (e.g. a set of occupied positions), a ColorPoint will never be recognized as occupying a Point's spot. So you've traded a contract violation for a substitutability violation. ### The deeper truth *There is no way to extend an instantiable class with a new value-significant field while preserving the equals/hashCode contract perfectly.* This is a fundamental limitation, not a coding mistake. ## The recommended resolutions 1. **Favor composition over inheritance (Effective Java).** Don't make `ColorPoint extends Point`. Instead, give `ColorPoint` a *private* `Point` field plus the color, and expose `asPoint()` if a Point view is needed. Now `ColorPoint`'s equals/hashCode are over `(point, color)` as a self-contained value type; there's no parent/child equality to reconcile, so no asymmetry and no LSP problem. 2. **Make value classes effectively final.** If a class has value-based equals/hashCode, prevent subclassing (mark it `final`, or use a `record` — records are implicitly final and generate compliant equals/hashCode from their components). This removes the hazard at the source. 3. **Abstract-root hierarchies with no instantiable equals-adding level.** It *is* acceptable for an **abstract** superclass to define equals/hashCode that concrete subclasses extend, *provided* you cannot create instances of an intermediate instantiable level that adds value fields — because then there's no parent *object* to compare against asymmetrically. ## Practical guidance for a tech lead - Default to `record`s or `final` value classes for anything used as a map/set key. - Reserve `getClass()`-based equals for genuinely final or sealed value types where substitutability of subtypes is intentionally disallowed. - If you must keep an open class comparable, document that subclasses **must not** add equals-relevant state, and enforce via review/architecture tests. - Remember the symptom in production: a value-typed item appears to be 'in' a `HashSet` from one reference and 'not in' from another, or duplicates slip into a set — a tell-tale of broken symmetry/hash linkage across a hierarchy.
- Why doesn't switching to getClass() in equals fully solve the problem?getClass() restores symmetry and the hashCode link, but it makes a subclass instance never equal to a parent instance, violating the Liskov Substitution Principle — a subtype that should be usable wherever the supertype is expected is now treated as a different value. It trades one defect for another.
- Do Java records make this go away?Largely, yes. Records are implicitly final, so you cannot subclass them to add value fields, and their generated equals/hashCode are derived consistently from the components. Combined with composition (a record holding another record), you avoid the hierarchy-equality reconciliation entirely.
It's like trying to make a passport (name) and a passport-with-visa (name + visa) treat each other as the same traveler. Border A ignores the visa and waves both through (symmetric only if everyone ignores it); border B demands an exact document match and rejects the upgraded passport as 'a different document.' You can't satisfy both — so you stop subtyping and just bundle the visa alongside a copy of the passport instead.
saying these in an interview costs you the question
- Claiming you can cleanly add a value field via subclassing without breaking the contract
- Using instanceof equals across a hierarchy without realizing it breaks symmetry
- Asserting getClass() equals is universally correct (it breaks Liskov)
- Mixing parent and value-extending child instances in the same HashSet
- Forgetting that the hashCode link breaks alongside the symmetry break