Loading content...
You have two $20 bills in your wallet. They're different pieces of paper—different serial numbers, different ink patterns, physically distinct objects. Yet for the purpose of buying coffee, they're identical. You don't care which one you use; they're equal in value.
This is the essence of object equality: different objects that should be treated as interchangeable for some purpose. Unlike identity (same memory), equality is a logical concept that you, the programmer, define based on your domain's requirements.
This page explores how to think about equality, how to implement it correctly, and how to avoid the subtle bugs that plague even experienced developers.
By the end of this page, you will understand: (1) The conceptual difference between identity and equality, (2) The equals() contract and its mathematical properties, (3) How to reason about what 'equal' means for your objects, (4) Common pitfalls that break equality implementations, and (5) The critical relationship between equals() and hashCode().
Object equality answers a fundamentally different question than identity: Do these two objects represent the same logical value?
Unlike identity, which is determined by memory addresses, equality is determined by meaning. Two Money objects with the same amount and currency are equal—even though they occupy different memory locations—because they represent the same monetary value.
Equality is Domain-Dependent:
What makes two objects 'equal' depends entirely on your problem domain. Consider a Person object:
There's no universal answer—you define equality based on what matters in your context.
12345678910111213141516171819202122232425262728293031323334353637
// Different equality definitions for the same "concept" // Version 1: Email-based equalityclass UserByEmail { private String email; private String name; @Override public boolean equals(Object o) { if (!(o instanceof UserByEmail)) return false; UserByEmail other = (UserByEmail) o; return this.email.equals(other.email); // Only email matters }} // Version 2: Full identity based equalityclass UserByAllFields { private String email; private String name; @Override public boolean equals(Object o) { if (!(o instanceof UserByAllFields)) return false; UserByAllFields other = (UserByAllFields) o; return this.email.equals(other.email) && this.name.equals(other.name); // Both fields matter }} // Same data, different equality results:UserByEmail u1 = new UserByEmail("alice@example.com", "Alice");UserByEmail u2 = new UserByEmail("alice@example.com", "Alice Smith");u1.equals(u2); // true - only email compared UserByAllFields u3 = new UserByAllFields("alice@example.com", "Alice");UserByAllFields u4 = new UserByAllFields("alice@example.com", "Alice Smith");u3.equals(u4); // false - names differChoosing which fields participate in equality is a design decision with real consequences. Too few fields: different things appear equal. Too many fields: logically same things appear different. Think carefully about what 'sameness' means in your domain before writing equals().
The equals() method isn't just any comparison function—it must satisfy a rigorous mathematical contract. Violating this contract causes subtle, hard-to-reproduce bugs in collections, algorithms, and virtually any code that compares objects.
The Official Contract (from Java's Object.equals specification):
For any non-null references x, y, and z, the following must hold:
| Property | Mathematical Form | Plain English |
|---|---|---|
| Reflexive | x.equals(x) == true | An object always equals itself |
| Symmetric | x.equals(y) == y.equals(x) | If A equals B, then B equals A |
| Transitive | x.equals(y) && y.equals(z) → x.equals(z) | If A=B and B=C, then A=C |
| Consistent | Multiple calls return same result (if objects unchanged) | Equality doesn't randomly change |
| Null-safe | x.equals(null) == false | Nothing equals null (except null itself) |
Why the Contract Matters:
These aren't arbitrary rules—they're what allow collections like HashSet, HashMap, and algorithms like binary search to function correctly. If your equals() violates symmetry, a HashSet might contain an object that you can't find. If it violates transitivity, the set's internal consistency breaks down.
123456789101112131415161718192021222324252627282930313233
// DANGEROUS: Symmetry violation in action class CaseInsensitiveString { private String value; @Override public boolean equals(Object o) { if (o instanceof CaseInsensitiveString) { return value.equalsIgnoreCase(((CaseInsensitiveString) o).value); } // MISTAKE: Trying to be "compatible" with String if (o instanceof String) { return value.equalsIgnoreCase((String) o); } return false; }} // The symmetry violation:CaseInsensitiveString cis = new CaseInsensitiveString("Hello");String s = "hello"; cis.equals(s); // true - CIS thinks it equals Strings.equals(cis); // false - String has no idea about CIS // What happens with collections:List<Object> list = new ArrayList<>();list.add(cis);list.contains(s); // Might be true or false depending on iteration order! // The fundamental problem:// You can't make YOUR class equal to OTHER classes you don't control// because you can't change their equals() to reciprocateNever try to make your class equal to objects of other classes you don't control. If ClassA.equals(ClassB) returns true, you cannot guarantee ClassB.equals(ClassA) returns true—and that breaks symmetry, which breaks everything.
A well-implemented equals() method follows a specific structure. Let's break down each component and understand why it exists.
1234567891011121314151617181920212223242526272829303132333435363738
public class Money { private final int amount; private final String currency; public Money(int amount, String currency) { this.amount = amount; this.currency = Objects.requireNonNull(currency); } @Override public boolean equals(Object o) { // Step 1: Identity check (performance optimization) if (this == o) return true; // Step 2: Null check (required by contract) if (o == null) return false; // Step 3: Type check // Option A: getClass() - strict type matching // Option B: instanceof - allows subclass equality if (getClass() != o.getClass()) return false; // Step 4: Cast (safe after type check) Money money = (Money) o; // Step 5: Field comparison // Primitives: use == // Objects: use Objects.equals() for null-safety return amount == money.amount && Objects.equals(currency, money.currency); } // CRITICAL: hashCode() must also be overridden (covered later) @Override public int hashCode() { return Objects.hash(amount, currency); }}this == o, we're comparing the object to itself. This is the fastest true case and satisfies reflexivity.null can never equal a non-null object. The contract requires returning false.This is a critical design choice. getClass() != o.getClass() means a ColoredPoint can never equal a Point, even if the coordinates match. instanceof allows subclass equality but can break symmetry if subclasses add fields. Choose based on your hierarchy's semantics—most experts recommend getClass() for safety.
One of the most contentious decisions in implementing equals() is choosing between instanceof and getClass() for type checking. Both have valid uses, and the wrong choice can break your code in subtle ways.
The Core Tradeoff:
getClass(): Strictest, safest, prevents cross-type equalityinstanceof: More flexible, allows subclass equality, but risks breaking contract1234567891011121314151617181920212223242526272829303132333435
// The classic problem: Point and ColoredPoint class Point { private final int x, y; @Override public boolean equals(Object o) { if (!(o instanceof Point)) return false; Point p = (Point) o; return x == p.x && y == p.y; }} class ColoredPoint extends Point { private final String color; @Override public boolean equals(Object o) { if (!(o instanceof ColoredPoint)) return false; ColoredPoint cp = (ColoredPoint) o; return super.equals(o) && color.equals(cp.color); }} // SYMMETRY VIOLATION:Point p = new Point(1, 2);ColoredPoint cp = new ColoredPoint(1, 2, "red"); p.equals(cp); // true - Point sees matching x,ycp.equals(p); // false - ColoredPoint demands ColoredPoint type // This breaks collections:Set<Point> set = new HashSet<>();set.add(p);set.contains(cp); // Unpredictable behavior!12345678910111213141516171819202122232425262728293031323334
// The safe solution: use getClass() class Point { private final int x, y; @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; Point p = (Point) o; return x == p.x && y == p.y; }} class ColoredPoint extends Point { private final String color; @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; ColoredPoint cp = (ColoredPoint) o; return color.equals(cp.color); }} // Now symmetry is preserved:Point p = new Point(1, 2);ColoredPoint cp = new ColoredPoint(1, 2, "red"); p.equals(cp); // false - different classescp.equals(p); // false - different classes // Trade-off: A Point(1,2) can NEVER equal a ColoredPoint(1,2,red)// even though coordinates match. Sometimes this is wrong too.Use getClass() when: (1) class is final, (2) subclasses add state, (3) you want maximum safety. Use instanceof when: (1) working with abstract base classes with no state, (2) implementing 'structural' equality (like interfaces), (3) you've verified symmetry across the hierarchy.
Different field types require different comparison strategies. Using the wrong approach leads to incorrect equality results or NullPointerExceptions.
The Field Type Guide:
| Field Type | Comparison Method | Why | Gotcha |
|---|---|---|---|
| Primitives (int, char, etc.) | == | Direct value comparison | None |
| float | Float.compare(a, b) == 0 | Handles NaN and -0.0 correctly | Float.NaN != Float.NaN with == |
| double | Double.compare(a, b) == 0 | Same as float | Precision issues possible |
| Object references | Objects.equals(a, b) | Null-safe, delegates to .equals() | Don't forget to override equals in the type |
| Arrays | Arrays.equals(a, b) | Element-by-element comparison | Use Arrays.deepEquals() for nested arrays |
| Collections | Objects.equals(a, b) | Lists, Sets, Maps have equals() | Order matters for Lists, not Sets |
12345678910111213141516171819202122232425262728293031323334353637383940414243
public class Measurement { private final int count; // primitive private final double value; // requires special handling private final String unit; // object reference private final double[] readings; // array @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Measurement that = (Measurement) o; return count == that.count // primitive: == && Double.compare(that.value, value) == 0 // double: Double.compare && Objects.equals(unit, that.unit) // object: Objects.equals (null-safe) && Arrays.equals(readings, that.readings); // array: Arrays.equals } @Override public int hashCode() { int result = Objects.hash(count, value, unit); result = 31 * result + Arrays.hashCode(readings); return result; }} // Why Float.compare and Double.compare?// // Problem 1: NaNdouble nan1 = Double.NaN;double nan2 = Double.NaN;nan1 == nan2; // false! (IEEE 754)Double.compare(nan1, nan2) == 0; // true// // Problem 2: Negative zerodouble negZero = -0.0;double posZero = 0.0;negZero == posZero; // trueDouble.compare(negZero, posZero); // -1 (different!) // For equals(), we usually want consistent behavior:// - NaN should equal NaN (reflexivity)// - -0.0 might or might not equal 0.0 (domain-dependent)Using == on object fields checks identity, not equality. firstName == other.firstName will often return false even when both strings contain 'Alice'. Always use Objects.equals() or the field's .equals() method (with null check).
Different languages have different conventions for equality, but the concepts remain the same. Understanding these differences helps when working across language boundaries.
| Language | Equality Method | Default Behavior | Contract Enforcement |
|---|---|---|---|
| Java | .equals() | Identity (Object.equals) | Programmer responsibility |
| Python | eq() | Identity (is) | Programmer responsibility |
| C# | .Equals() / == | Identity for classes, value for structs | Programmer responsibility |
| Kotlin | == / .equals() | Structural equality (calls equals) | data class generates |
| Swift | Equatable protocol | Must implement == | Compiler helps with Hashable |
| Go | == on structs | Field-by-field comparison | Automatic for comparable fields |
| Rust | Eq trait / == | Derived or implemented | Compiler verifies trait bounds |
123456789101112131415161718192021222324252627282930
# Python: __eq__ method defines equality class Money: def __init__(self, amount, currency): self.amount = amount self.currency = currency def __eq__(self, other): # Type check if not isinstance(other, Money): return NotImplemented # Allows other to try # Value comparison return self.amount == other.amount and self.currency == other.currency def __hash__(self): # Must implement if __eq__ is implemented and object is hashable return hash((self.amount, self.currency)) # Usagem1 = Money(100, "USD")m2 = Money(100, "USD")m3 = Money(200, "USD") m1 == m2 # True - __eq__ calledm1 == m3 # False - different amountm1 is m2 # False - different objects (identity) # Python's NotImplemented return:# If __eq__ returns NotImplemented, Python tries other.__eq__(self)# This helps maintain symmetry with types that know about each other1234567891011121314151617181920212223242526272829
// Kotlin: data class generates equals/hashCode automatically data class Money(val amount: Int, val currency: String) // Kotlin automatically generates:// - equals() comparing all constructor properties// - hashCode() based on all constructor properties// - toString() representation// - copy() function// - componentN() functions for destructuring val m1 = Money(100, "USD")val m2 = Money(100, "USD")val m3 = Money(200, "USD") println(m1 == m2) // true (structural equality, calls equals)println(m1 === m2) // false (referential equality, identity)println(m1 == m3) // false // For non-data classes, implement manually:class Product(val id: String, val name: String) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Product) return false return id == other.id // Only ID matters for equality } override fun hashCode(): Int = id.hashCode()}Modern languages like Kotlin (data class), Scala (case class), and Python (dataclasses) can generate correct equals/hashCode implementations. Use these features when possible—they're less error-prone than manual implementations.
Even experienced developers make mistakes with equals(). Here are the most common pitfalls and how to avoid them.
public boolean equals(Money other) instead of equals(Object other)Object parameter; use @Override annotation to catch this1234567891011121314151617181920212223
// WRONG: This is overLOADing, not overRIDingpublic class Money { public boolean equals(Money other) { // Wrong parameter type! return this.amount == other.amount; }} // What happens:Money m1 = new Money(100);Money m2 = new Money(100);Object o = m2; m1.equals(m2); // Uses our method: truem1.equals(o); // Uses Object.equals(): false! Wrong method called // CORRECT: Override Object.equalspublic class Money { @Override // This annotation forces compiler to verify override public boolean equals(Object other) { if (!(other instanceof Money)) return false; return this.amount == ((Money) other).amount; }}12345678910111213141516171819202122
// BROKEN: equals without hashCodeclass Product { private String id; @Override public boolean equals(Object o) { if (!(o instanceof Product)) return false; return id.equals(((Product) o).id); } // hashCode() NOT overridden - uses Object.hashCode()} // The disaster:Set<Product> set = new HashSet<>();set.add(new Product("ABC123")); Product lookup = new Product("ABC123");lookup.equals(set.iterator().next()); // true! They're equal!set.contains(lookup); // false! Can't find it! // Why? HashSet first checks hashCode to find the bucket.// Different hashCodes → different buckets → never checked for equality.1234567891011121314151617181920212223242526272829
// DANGEROUS: Mutable field in equalsclass User { private String id; private String email; // Mutable! public void setEmail(String email) { this.email = email; } @Override public boolean equals(Object o) { if (!(o instanceof User)) return false; return email.equals(((User) o).email); // Mutable field! } @Override public int hashCode() { return email.hashCode(); } // Mutable!} // The disaster:Set<User> users = new HashSet<>();User alice = new User("1", "alice@old.com");users.add(alice); // Added to bucket for hashCode("alice@old.com") users.contains(alice); // true alice.setEmail("alice@new.com"); // Mutate the field! users.contains(alice); // false! Object is now in wrong bucket// Even worse: the object is in the set but unfindable// Memory leak: can't remove it either!Fields used in equals and hashCode should be immutable (final). If you must use mutable fields, never put such objects in hash-based collections—or accept that modifying fields will corrupt the collection.
We've explored the complex world of object equality—defining what it means for different objects to represent 'the same value.' Let's consolidate the key insights:
What's Next:
We've established that equals() and hashCode() must be overridden together. The next page dives deep into implementing equals() and hashCode()—the mechanics of getting it right, the relationship between these two methods, and the specific patterns that ensure your objects work correctly in collections.
You now understand object equality—the concept of same value. You know the contract, the common pitfalls, and when to use different comparison approaches. Next: Implementing equals() and hashCode().