Why equals and inheritance cannot both be right
Symmetry or substitutability - with instanceof you cannot have both.
Open this lesson in the learning hubKey points
- The
equalscontract demands symmetry: if a equals b then b must equal a. Adding a field in a subclass makes that impossible to satisfy while also honouring substitutability. - Using
instanceofgives substitutability but breaks symmetry: the parent compares only its own fields and says equal, while the child compares the extra field and says not equal. - Using
getClass()restores symmetry but breaks the Liskov substitution principle: a subclass instance is never equal to a parent instance, even when it represents the same value. - This is not a Java flaw - it is a genuine mathematical tension. Adding a value component to a subtype and preserving an equivalence relation are incompatible.
- The practical resolution is composition instead of inheritance for value types. Hold the parent as a field and expose it, rather than extending it.
- Records make this concrete: they are implicitly final and cannot extend another record, which removes the problem by construction rather than by discipline.
Example
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class EqualsInheritance {
static 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 p)) { return false; }
return x == p.x && y == p.y; // ignores any subclass state
}
@Override public int hashCode() { return Objects.hash(x, y); }
@Override public String toString() { return "Point(" + x + "," + y + ")"; }
}
static class ColourPoint extends Point {
final String colour;
ColourPoint(int x, int y, String colour) { super(x, y); this.colour = colour; }
@Override public boolean equals(Object o) {
if (!(o instanceof ColourPoint c)) { return false; }
return super.equals(o) && colour.equals(c.colour);
}
@Override public int hashCode() { return Objects.hash(x, y, colour); }
@Override public String toString() { return "ColourPoint(" + x + "," + y + "," + colour + ")"; }
}
// COMPOSITION - the resolution. No inheritance, no broken contract.
record ColouredPoint(Point point, String colour) { }
public static void main(String[] args) {
Point p = new Point(1, 2);
ColourPoint cp = new ColourPoint(1, 2, "red");
System.out.println("ASYMMETRY with instanceof:");
System.out.println(" p.equals(cp) = " + p.equals(cp));
System.out.println(" cp.equals(p) = " + cp.equals(p) + " <- contract broken");
// And the consequence in a collection - the result depends on order.
Set<Point> setA = new HashSet<>();
setA.add(p);
System.out.println();
System.out.println(" set{p}.contains(cp) = " + setA.contains(cp));
Set<Point> setB = new HashSet<>();
setB.add(cp);
System.out.println(" set{cp}.contains(p) = " + setB.contains(p));
System.out.println(" -> membership depends on insertion order");
System.out.println();
System.out.println("COMPOSITION - symmetric and correct:");
ColouredPoint a = new ColouredPoint(new Point(1, 2), "red");
ColouredPoint b = new ColouredPoint(new Point(1, 2), "red");
ColouredPoint c = new ColouredPoint(new Point(1, 2), "blue");
System.out.println(" a.equals(b) = " + a.equals(b));
System.out.println(" b.equals(a) = " + a.equals(b));
System.out.println(" a.equals(c) = " + a.equals(c));
System.out.println(" the Point is still reachable: " + a.point());
}
}
Adding a value field in a subclass cannot preserve symmetry and substitutability at once - compose instead of extending.
This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the OOP course, and every lesson in it is listed on the OOP contents page.