Why equals and inheritance cannot both be right

OOP · lesson 40 of 43 · 7 min read

Symmetry or substitutability - with instanceof you cannot have both.

Open this lesson in the learning hub

Key points

  • The equals contract 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 instanceof gives 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.