JAVA / NESTED CLASSES AND THE OBJECT CONTRACT
The getClass versus instanceof choice in equals
Choose between getClass() and instanceof in equals by reasoning about symmetry and substitutability, and write each form correctly across a hierarchy.
What you will learn
- Spot the symmetry break when an instanceof equals meets a subclass that adds a field
- Write a getClass-based equals with its null guard and a super.equals call in subclasses
- Reserve instanceof for final classes or interface-defined equality like List and Set
- Explain why a stateless subclass fails a HashSet lookup under a getClass-based equals
Understanding The getClass versus instanceof choice in equals
The two tests ask different questions. `getClass() != o.getClass()` asks whether the argument has exactly the same runtime class as the receiver; `o instanceof Loose` asks whether it is at least a Loose. Since equals defines an equivalence relation, that one line decides which objects are even eligible to be related, which is why symmetry and transitivity live or die there rather than in the field comparisons below it.
With instanceof, a subclass that adds state ends up with two rules in play: the base compares only x, the subclass demands x and tag. So `base.equals(sub)` consults the loose rule and says true, while `sub.equals(base)` consults the strict rule and says false. Trying to patch that by having the subclass ignore its tag whenever the argument is a plain base instance buys transitivity trouble instead: Tagged(1,"red") equals Loose(1), Loose(1) equals Tagged(1,"blue"), yet the two tagged objects are unequal. The damage is not theoretical, because the JDK does not promise which operand it uses as the receiver; `contains` calls equals on the argument, while `AbstractList.equals` calls it on the stored element.
getClass() buys symmetry by refusing substitutability. Because getClass() reports the runtime class, an inherited getClass-based equals automatically narrows itself for every subclass, so a subclass that adds no state at all — an instrumented subclass, an anonymous subclass, a Hibernate or Mockito proxy — can never equal the base value it represents, and lookups quietly return false. The practical rule: use getClass for extendable value classes whose subclasses may add state, use instanceof when equality is fixed by an interface or abstract base that all implementations must honour, and prefer final classes, records, or composition so the dilemma never arises.
public class EqualsTypeCheck {
static class Loose {
final int x;
Loose(int x) { this.x = x; }
@Override public boolean equals(Object o) {
if (!(o instanceof Loose)) return false;
return ((Loose) o).x == x;
}
@Override public int hashCode() { return Integer.hashCode(x); }
}
static class LooseTagged extends Loose {
final String tag;
LooseTagged(int x, String tag) { super(x); this.tag = tag; }
@Override public boolean equals(Object o) {
if (!(o instanceof LooseTagged)) return false;
LooseTagged other = (LooseTagged) o;
return other.x == x && other.tag.equals(tag);
}
@Override public int hashCode() { return 31 * Integer.hashCode(x) + tag.hashCode(); }
}
static class Strict {
final int x;
Strict(int x) { this.x = x; }
@Override public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
return ((Strict) o).x == x;
}
@Override public int hashCode() { return Integer.hashCode(x); }
}
static class StrictTagged extends Strict {
final String tag;
StrictTagged(int x, String tag) { super(x); this.tag = tag; }
@Override public boolean equals(Object o) {
if (!super.equals(o)) return false; // the getClass test runs inside Strict.equals
return ((StrictTagged) o).tag.equals(tag);
}
@Override public int hashCode() { return 31 * super.hashCode() + tag.hashCode(); }
}
public static void main(String[] args) {
Loose lb = new Loose(1);
Loose ls = new LooseTagged(1, "red");
System.out.println("instanceof: base.equals(sub) = " + lb.equals(ls));
System.out.println("instanceof: sub.equals(base) = " + ls.equals(lb));
Strict sb = new Strict(1);
Strict ss = new StrictTagged(1, "red");
System.out.println("getClass: base.equals(sub) = " + sb.equals(ss));
System.out.println("getClass: sub.equals(base) = " + ss.equals(sb));
System.out.println("getClass: sub.equals(sub) = " + ss.equals(new StrictTagged(1, "red")));
}
}The type test at the top of equals is a choice between exact class identity and substitutability, and once a subclass adds state to the comparison you cannot have both.
Worked examples
What getClass costs you
A subclass that adds no state stops being equal to its base value, and a HashSet lookup misses.
import java.util.HashSet;
import java.util.Set;
public class StrictAndSubclasses {
static class Money {
final long cents;
Money(long cents) { this.cents = cents; }
@Override public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
return ((Money) o).cents == cents;
}
@Override public int hashCode() { return Long.hashCode(cents); }
}
// Adds no state and no new equality rule: it only prints differently.
static class TaggedMoney extends Money {
TaggedMoney(long cents) { super(cents); }
@Override public String toString() { return cents + "c (tagged)"; }
}
public static void main(String[] args) {
Set<Money> prices = new HashSet<>();
prices.add(new TaggedMoney(500));
System.out.println(prices.contains(new Money(500)));
System.out.println(new Money(500).equals(new TaggedMoney(500)));
System.out.println(new TaggedMoney(500).equals(new TaggedMoney(500)));
System.out.println(prices.contains(new TaggedMoney(500)));
}
}Example explained
Line 1TaggedMoney declares no field, yet the inherited equals rejects Money because getClass() returns the runtime class TaggedMoney, not Money.
Line 2HashSet.contains hashes the argument to the same bucket (both hash to 500) and then calls the argument's equals against the stored key, so the strict type test kills the match after the hash already agreed.
Line 3The third call is true because both operands are TaggedMoney: getClass-based equality is fine as long as you never compare across levels of the hierarchy.
Line 4The last call shows the workaround is only to keep using the same class everywhere, which is impossible when a framework hands you a generated proxy subclass.
Null, wrong types, and final classes
On a final class the two tests can never disagree, but they differ in how much guarding they need.
public class TypeTestDetails {
static final class Isbn {
private final String digits;
Isbn(String digits) { this.digits = digits; }
@Override public boolean equals(Object o) {
return o instanceof Isbn other && other.digits.equals(digits);
}
@Override public int hashCode() { return digits.hashCode(); }
}
static final class Ean {
private final String digits;
Ean(String digits) { this.digits = digits; }
@Override public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
return ((Ean) o).digits.equals(digits);
}
@Override public int hashCode() { return digits.hashCode(); }
}
public static void main(String[] args) {
Isbn a = new Isbn("9780134685991");
System.out.println(a.equals(null));
System.out.println(a.equals("9780134685991"));
System.out.println(a.equals(new Isbn("9780134685991")));
Ean e = new Ean("9780134685991");
System.out.println(e.equals(a));
System.out.println(e.equals(new Ean("9780134685991")));
}
}Example explained
Line 1`o instanceof Isbn other` is false for null, so the instanceof form gets the required null answer for free and binds the cast variable in the same line.
Line 2The getClass form needs the explicit `o == null` guard first, because `null.getClass()` would throw NullPointerException instead of returning false.
Line 3`a.equals("9780134685991")` returns false rather than throwing, since the type test runs before any cast; a bare cast would raise ClassCastException.
Line 4Both classes are final, so no subclass can ever make the two tests disagree and the choice here is purely about which line reads better.
Important notes
getClass() inside a base class method returns the runtime class of `this`, so one getClass-based equals inherited by five subclasses automatically becomes five strict, non-overlapping rules; that is why `super.equals(o)` alone is enough in the subclass.
Records generate equals for you and are implicitly final, so the choice never arises there. Frameworks that hand you generated subclasses (JPA proxies, mocks) are the main real-world reason a team picks instanceof despite its risks. The `o instanceof Isbn other` form needs Java 16 or later.
Common mistakes
Writing `if (getClass() != o.getClass()) return false;` without the `o == null` check: `a.equals(null)` throws NullPointerException instead of returning false, and every collection that probes with null blows up.
Keeping an instanceof-based equals in the base and then adding a subclass with an extra field: `base.equals(sub)` is true while `sub.equals(base)` is false, so whether `list.contains(x)` or `set.remove(x)` succeeds depends on which of the two objects the library happens to use as the receiver.
Overriding equals in a subclass of a getClass-based class and comparing only the new field, without `super.equals(o)`: the class check and the inherited fields are both skipped, so StrictTagged(1,"red") and StrictTagged(2,"red") compare equal.
Try it yourself
Change, predict, then run
Write a Point with an instanceof-based equals on x and y, plus a Point3D subclass that adds z and compares all three, then print the four combinations of equals between one Point(1,2) and one Point3D(1,2,0). Switch Point and Point3D to the getClass form with super.equals and note which of the four results flip.
Open the Java workspaceCheck your understanding
Loose.equals uses `o instanceof Loose` and compares x; LooseTagged adds tag and compares x plus tag. So `base.equals(sub)` is true but `sub.equals(base)` is false. Which contract rule is broken, and why does it matter in a collection?
- Symmetry, so whether a lookup finds a match depends on which of the two objects is stored and which is passed as the argument
- Reflexivity, because LooseTagged is no longer equal to itself once it adds a field
- Transitivity, and it only becomes a real problem once a third subclass exists
- The hashCode contract, since the two objects hash differently and the lookup would fail anyway
Show answer
Symmetry requires `a.equals(b)` and `b.equals(a)` to agree, and these two disagree; because the JDK never promises which operand receives the call (contains invokes equals on the argument, AbstractList.equals on the stored element), the same pair of objects can match in one collection and not another. Transitivity is a different rule that needs three objects, and here two are already enough to show the defect; differing hash codes are a separate consequence, not the rule that the true/false disagreement violates.