Why can you generally not extend an instantiable class, add a value-significant field, and still satisfy the equals contract? What is the recommended alternative?
answer
- instanceof -> symmetry/transitivity risk; getClass -> LSP violation
- Point vs ColorPoint adding color is the canonical case
- No value-adding subclass of an instantiable class keeps the contract
- Fix = composition + asPoint() view
- records use getClass-equals and are final on purpose
basics
~20 sAdding a value field in a subclass forces a choice that breaks either symmetry or transitivity: the parent ignores the new field but the child checks it. Instead of inheriting, hold the parent as a field (composition) and expose a view.
solid answer
~50 sIf a superclass like Point compares x,y and a subclass ColorPoint adds color, you must decide how a Point and a ColorPoint compare. If ColorPoint.equals ignores color when the other side is a plain Point (to stay symmetric), transitivity breaks: two different-colored ColorPoints can each equal the same Point yet not each other. If ColorPoint requires color, then point.equals(colorPoint) is true but colorPoint.equals(point) is false — asymmetric. Using getClass() instead of instanceof restores symmetry/transitivity but violates the Liskov substitution principle, because a ColorPoint can no longer be used wherever a Point is expected for equality. The standard resolution (Effective Java) is composition over inheritance: don't extend Point; give ColorPoint a private Point field plus a color, and add an asPoint() view method. Each class then compares only objects of its own kind, so the contract holds. Records and sealed types also sidestep the problem.
code
java · 21 lines// Composition over inheritance: ColorPoint HAS-A Point
public final class ColorPoint {
private final Point point;
private final Color color;
public ColorPoint(int x, int y, Color color) {
this.point = new Point(x, y);
this.color = color;
}
/** Explicit view when Point semantics are wanted. */
public Point asPoint() { return point; }
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint cp)) return false;
return cp.point.equals(point) && cp.color.equals(color);
}
@Override public int hashCode() {
return java.util.Objects.hash(point, color);
}
}go deeper
Recognizes that subclasses complicate equals and that you should be careful comparing different types.
Can show the Point/ColorPoint asymmetry or transitivity break and knows instanceof vs getClass differ.
Explains the full lose-lose (symmetry vs transitivity vs LSP) and recommends composition with a view; cites the Effective Java rule.
Frames it as a domain-modeling decision — value types via records/sealed types, when entity identity should override value equality, and how to enforce this across a codebase (linters, EqualsVerifier, API conventions).
## The problem in one example ```java class Point { final int x, y; Point(int x, int y){ this.x=x; this.y=y; } @Override public boolean equals(Object o){ if(!(o instanceof Point)) return false; Point p=(Point)o; return p.x==x && p.y==y; } } class ColorPoint extends Point { final Color color; ColorPoint(int x,int y,Color c){ super(x,y); this.color=c; } } ``` We want `ColorPoint` to also compare its `color`. How should a `ColorPoint` and a plain `Point` compare? Every option breaks something. ## Attempt A — ColorPoint.equals requires equal color (strict) ```java @Override public boolean equals(Object o){ if(!(o instanceof ColorPoint)) return false; return super.equals(o) && ((ColorPoint)o).color==color; } ``` Now: - `point.equals(colorPoint)` → uses `Point.equals`, sees a Point, returns **true** (ignores color). - `colorPoint.equals(point)` → `point` is not a `ColorPoint`, returns **false**. **Asymmetric.** Violates clause 2. ## Attempt B — ColorPoint ignores color when comparing to a plain Point (mixed) ```java @Override public boolean equals(Object o){ if(!(o instanceof Point)) return false; if(!(o instanceof ColorPoint)) return o.equals(this); // blind to color return super.equals(o) && ((ColorPoint)o).color==color; } ``` This fixes symmetry but breaks **transitivity**: ``` ColorPoint a = (1,2, RED); Point b = (1,2); ColorPoint c = (1,2, BLUE); a.equals(b) // true (color ignored vs a plain Point) b.equals(c) // true a.equals(c) // false (both ColorPoints -> color compared, RED != BLUE) ``` `a=b` and `b=c` but `a≠c`. Violates clause 3. It can also cause **infinite recursion** between two color-point subclasses that each defer to the other. ## Attempt C — use getClass() instead of instanceof ```java @Override public boolean equals(Object o){ if(o==null || o.getClass()!=getClass()) return false; Point p=(Point)o; return p.x==x && p.y==y; } ``` Now only objects of the **exact same class** can be equal, so a `Point` and a `ColorPoint` are never equal in either direction — symmetry and transitivity hold. **But** this violates the **Liskov Substitution Principle (LSP)**: a subclass instance should be usable anywhere its superclass is. A `ColorPoint` passed where a `Point` is expected (e.g. a `Set<Point>` of unit points used for a containment check) will fail equality with genuine `Point`s, breaking polymorphic code that legitimately treats a `ColorPoint` as a `Point`. So `getClass()` trades one contract for another principle. ## The fundamental fact > There is **no way** to extend an *instantiable* class and add a *value component* while preserving the `equals` contract. (Bloch, *Effective Java*.) The tension is intrinsic: an `instanceof`-based equals invites a subtype on one side that the supertype can't reciprocate; a `getClass()`-based equals forbids substitutability. You can't have value-adding inheritance *and* a clean contract *and* LSP simultaneously. ## The recommended fix — composition + a view ```java class ColorPoint { private final Point point; // HAS-A, not IS-A private final Color color; ColorPoint(int x,int y,Color c){ point=new Point(x,y); color=c; } Point asPoint(){ return point; } // explicit view when you want Point semantics @Override public boolean equals(Object o){ if(!(o instanceof ColorPoint cp)) return false; return cp.point.equals(point) && cp.color.equals(color); } } ``` Now `ColorPoint` and `Point` are different types that never claim to be equal to each other; each `equals` only ever compares its own kind, so reflexivity/symmetry/transitivity all hold. When you genuinely want to treat a `ColorPoint` as a point, you call `asPoint()` explicitly. ## Exceptions / nuances - Adding a subclass with **no value-significant field** (e.g. only behavior, no new state in equals) is fine — it inherits `equals` untouched. - An **abstract** superclass that is never instantiated can let subclasses define equals among themselves safely, because there is no concrete superclass instance to mismatch. - **`record`** classes generate a `getClass()`-based equals and are implicitly final, deliberately avoiding the inheritance trap; prefer records / sealed hierarchies for value types. ## Takeaway Value-adding subclassing forces a lose-lose between symmetry, transitivity, and LSP. Favor **composition over inheritance**: embed the base type as a field and expose a view, or use records for value semantics.
- Why does using getClass() in equals violate the Liskov Substitution Principle?Because a subclass instance can no longer stand in for its superclass in equality-based code: getClass() makes a ColorPoint never equal to a plain Point even when their point coordinates match, so any client treating ColorPoint as a Point (e.g. Set<Point> membership) gets wrong answers.
- Why are Java records exempt from this trap?Records are implicitly final and generate a getClass()-based equals over their components; you can't subclass them to add a value field, so the symmetry/transitivity/LSP conflict never arises.
Inheritance equality is like asking 'is this dog the same as that animal?' — once the dog cares about breed but the animal doesn't, the two can't consistently agree. Better to say the dog HAS an animal description plus a breed, and compare dogs only to dogs.
saying these in an interview costs you the question
- Claiming instanceof and getClass are interchangeable in equals — they trade different guarantees.
- Saying you can keep the contract by 'just calling super.equals' — that's exactly Attempt A/B which break.
- Recommending getClass() without acknowledging the LSP cost.
- Forgetting that adding a subclass with no new value field is actually fine.