JAVA / NESTED CLASSES AND THE OBJECT CONTRACT
equals and the equivalence contract
Write an equals(Object) that satisfies the reflexive, symmetric, transitive, consistent and null-safe contract, and spot code that quietly violates it.
What you will learn
- Override equals(Object), not equals(MyType), and let @Override prove it.
- Check reflexivity, symmetry and transitivity with three equal instances.
- Compare double fields with Double.compare and nullable fields with Objects.equals.
- Tolerance windows break transitivity; cross-type leniency breaks symmetry.
Understanding equals and the equivalence contract
Object.equals compares references, and that default is right for most classes: two separately created objects are two different things even if their fields currently agree. You override equals only when you mean to declare that your class is a value type, where the state is the identity and any Version(2, 7) is interchangeable with any other. The parameter type must be Object, because every caller that will ever reach your method (List.contains, map lookups, Objects.equals, assertion libraries) holds your object in an Object-typed variable and cannot see a narrower signature.
The five clauses in the javadoc, reflexive, symmetric, transitive, consistent, and false for null, are not style advice. Together they say that equals is an equivalence relation, which means it cuts your objects into disjoint groups, and once that holds, the phrase the group containing x is well defined. Code can then deduplicate, cache, look up and use your object as a map key without caring in what order it saw the objects. Break one clause and there is no partition left: a lookup can hit or miss depending on which side of the comparison a library puts your object, and two passes over the same data can disagree.
The body follows a fixed shape for good reasons. Testing this == o first is a fast path and it also nails reflexivity; a single instanceof test rejects both unrelated types and null, so the cast after it cannot fail. Then compare exactly the fields that make up the value: == for integral primitives and booleans, Double.compare for double and float so that a NaN field still equals itself, Objects.equals for references that may be null, Arrays.equals for array fields. Anything beyond that, a tolerance window, a case-insensitive shortcut against String, a derived or cached field, a timestamp, is what turns a working equals into an order-dependent one.
import java.util.List;
public class EqualsContract {
static final class Version {
private final int major;
private final int minor;
Version(int major, int minor) {
this.major = major;
this.minor = minor;
}
@Override
public boolean equals(Object o) {
if (this == o) return true; // fast path, and reflexivity for free
if (!(o instanceof Version)) return false; // rejects null and foreign types
Version other = (Version) o;
return major == other.major && minor == other.minor;
}
@Override
public int hashCode() { // the required partner method
return 31 * major + minor;
}
@Override
public String toString() {
return major + "." + minor;
}
}
public static void main(String[] args) {
Version a = new Version(2, 7);
Version b = new Version(2, 7);
Version c = new Version(2, 7);
Version far = new Version(3, 0);
System.out.println("reflexive: " + a.equals(a));
System.out.println("symmetric: " + (a.equals(b) == b.equals(a)));
System.out.println("transitive: " + (a.equals(b) && b.equals(c) && a.equals(c)));
System.out.println("null: " + a.equals(null));
System.out.println("other type: " + a.equals("2.7"));
System.out.println("differs: " + a.equals(far));
System.out.println("contains: " + List.of(new Version(1, 0), a).contains(b));
}
}equals must implement an equivalence relation, because the JDK treats equality as a partition of your objects rather than as a similarity test.
Worked examples
The overload that is not an override
Declaring the parameter as your own type compiles fine but leaves Object.equals in charge wherever it matters.
import java.util.ArrayList;
import java.util.List;
public class Overloaded {
static final class Tag {
private final String name;
Tag(String name) { this.name = name; }
// Not an override: the parameter is Tag, so Object.equals is still inherited
public boolean equals(Tag other) {
return other != null && name.equals(other.name);
}
}
public static void main(String[] args) {
Tag t1 = new Tag("beta");
Tag t2 = new Tag("beta");
Object asObject = t2;
List<Tag> tags = new ArrayList<>();
tags.add(t1);
System.out.println("t1.equals(t2) = " + t1.equals(t2));
System.out.println("t1.equals(asObject) = " + t1.equals(asObject));
System.out.println("tags.contains(t2) = " + tags.contains(t2));
}
}Example explained
Line 1equals(Tag other) adds a second method next to the inherited equals(Object) instead of replacing it.
Line 2t1.equals(t2) picks the Tag version because overload resolution uses the static type of the argument, which is why direct calls look correct.
Line 3t1.equals(asObject) has static type Object, so it resolves to Object.equals and compares references.
Line 4ArrayList only knows equals(Object), so contains reaches the identity version and reports the tag as absent; writing @Override on the Tag method would have failed to compile.
A generous equals that breaks symmetry
Accepting a foreign type makes the same comparison give two answers depending on which object is asked.
import java.util.List;
public class SymmetryBreak {
static final class Celsius {
private final int degrees;
Celsius(int degrees) { this.degrees = degrees; }
@Override
public boolean equals(Object o) {
if (o instanceof Celsius) {
return degrees == ((Celsius) o).degrees;
}
if (o instanceof Integer) { // no Integer can return the favour
return degrees == (Integer) o;
}
return false;
}
@Override
public int hashCode() {
return degrees;
}
}
public static void main(String[] args) {
Celsius twenty = new Celsius(20);
Integer plainTwenty = 20;
System.out.println("twenty.equals(plainTwenty) = " + twenty.equals(plainTwenty));
System.out.println("plainTwenty.equals(twenty) = " + plainTwenty.equals(twenty));
System.out.println("List.of(twenty).contains(plainTwenty) = " + List.of(twenty).contains(plainTwenty));
System.out.println("List.of(plainTwenty).contains(twenty) = " + List.of(plainTwenty).contains(twenty));
}
}Example explained
Line 1The second instanceof branch unboxes the Integer and compares it to degrees, so the forward call is true.
Line 2Integer.equals only accepts Integer, so the reverse call is false and the relation is asymmetric.
Line 3contains calls equals on the argument, so the list holding the Celsius asks Integer.equals and reports a miss.
Line 4The other list reverses the roles and reports a hit, so one question now has two answers depending on where the value happens to be stored.
Tolerance destroys transitivity
A close enough comparison makes deduplication depend on the order the values arrive in.
import java.util.ArrayList;
import java.util.List;
public class TransitivityBreak {
// Counter-example, not a model: hashCode is deliberately absent
static final class Reading {
private final double value;
Reading(double value) { this.value = value; }
@Override
public boolean equals(Object o) {
return o instanceof Reading
&& Math.abs(value - ((Reading) o).value) < 0.5;
}
}
static int uniqueCount(List<Reading> input) {
List<Reading> unique = new ArrayList<>();
for (Reading r : input) {
if (!unique.contains(r)) unique.add(r);
}
return unique.size();
}
public static void main(String[] args) {
Reading a = new Reading(1.0);
Reading b = new Reading(1.4);
Reading c = new Reading(1.8);
System.out.println("a.equals(b) = " + a.equals(b));
System.out.println("b.equals(c) = " + b.equals(c));
System.out.println("a.equals(c) = " + a.equals(c));
System.out.println("unique in [a, b, c] = " + uniqueCount(List.of(a, b, c)));
System.out.println("unique in [b, a, c] = " + uniqueCount(List.of(b, a, c)));
}
}Example explained
Line 11.0 and 1.4 are within the window and so are 1.4 and 1.8, but 1.0 and 1.8 are 0.8 apart, so equality does not carry across.
Line 2uniqueCount compares each incoming reading only against the survivors already kept, so what counts as a duplicate depends on what arrived first.
Line 3Starting from 1.4 makes both neighbours look like duplicates and collapses the list to one element, while starting from 1.0 keeps two.
Line 4Nothing throws and no call site looks wrong, which is why tolerance in equals usually shows up much later as a wrong count.
Important notes
equals must never throw for any argument, including null and completely unrelated types, which is why one instanceof test is enough and a separate null check is redundant.
A record generates a contract-correct equals from its component list, so writing this method by hand is only worth it when the class cannot be a record or a component needs special comparison.
Common mistakes
Writing equals(MyType other) instead of equals(Object): the direct call looks correct in your own test, but contains, remove, indexOf and map keys keep using reference equality and lookups silently miss.
Casting before the type test, or adding an explicit null branch that throws: equals(null) or a scan over a mixed collection then fails with ClassCastException or NullPointerException instead of just returning false.
Comparing double fields with == or with a tolerance: == leaves an object holding NaN unequal to itself, which loses reflexivity, and a tolerance makes deduplication and contains depend on input order.
Try it yourself
Change, predict, then run
Write a Money class with a String currency and a long cents field, give it a correct equals, and print the reflexive, symmetric, transitive, null and wrong-type checks for two equal instances. Then change the currency comparison to == and see which check fails once one instance is built with new String("EUR").
Open the Java workspaceCheck your understanding
A class compares itself to another instance with Math.abs(this.v - other.v) <= 1. Instances hold v = 0, 1 and 2. Which contract clause does this break, and what is the visible symptom?
- Symmetry, because a.equals(b) and b.equals(a) can disagree.
- Transitivity, because a equals b and b equals c while a does not equal c, so a deduplication loop keeps a different number of items depending on the order it sees them.
- Reflexivity, because an instance with a large value stops equalling itself.
- Nothing, because the same window is applied in both directions, so the relation is still an equivalence relation.
Show answer
The window links 0 to 1 and 1 to 2 but leaves 0 and 2 two steps apart, so equality no longer partitions the objects and a dedup loop keeps one or two survivors depending on which reading it processes first. Option 0 is tempting because the rule feels sloppy, but abs(x - y) is symmetric, so both directions always agree; option 3 mistakes that symmetry for the whole contract, and reflexivity is safe because the distance from a value to itself is zero.