Equality and hash contracts: make values safe keys

Separate object identity from value equality, then keep equality inputs stable when a value enters a hash-based collection. Relationships and copying · Object-oriented programming.

Two points can carry the same coordinates while remaining two different objects. Put one into a set, edit its coordinates, and the set may still contain it but fail to find it. The missing idea is a contract between equality and hashing. A hash is a number a collection computes from a value to jump straight to one numbered shelf (a bucket) instead of scanning every entry.

Ask identity and value questions separately

Suppose Point(2, 3) is created twice. Identity asks whether both names reach one object. Value equality asks whether the two objects represent the same point according to the type's rule. Those answers can differ: two objects can be equal without being identical, and one object is always identical to itself.

In Java, == compares references for ordinary objects. A value type can override equals so two same-type points with the same coordinates compare equal. In Python, is asks for identity and == asks the objects for their equality behavior. Neither language makes “same displayed fields” a universal rule for every class; the type defines the value boundary.

record Point(int x, int y) {}

Point a = new Point(2, 3);
Point b = new Point(2, 3);
System.out.println(a == b);       // false: distinct objects
System.out.println(a.equals(b));  // true: equal record values
from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: int
    y: int

a = Point(2, 3)
b = Point(2, 3)
print(a is b)  # False: distinct objects
print(a == b)  # True: equal field values

Follow equality into a hash bucket

A hash-based set or map computes the hash from a value's equality fields, jumps to that bucket, and then uses equality to decide whether a candidate key in that bucket matches. The contract is one-way: if a.equals(b) or a == b says the values are equal, their hashes must be equal. The reverse does not hold. Two unequal values can produce the same hash; that collision is expected to be resolved by equality.

For Java, overriding equals generally means overriding hashCode with the same equality inputs. The Object contract also expects the hash to remain stable while those inputs remain unchanged. Python's data model has the same operational rule for dictionary and set keys: equal objects have equal hashes, and the hash must remain stable for the key's lifetime in the collection.

The scenes below draw a table of eight buckets, numbered 0 to 7, with a teaching hash: add an object's integer fields, then take the remainder after dividing by 8. Point(2, 3) and Point(3, 2) both give 5, so they share bucket 5. Typical library hashes of these two points differ, but a hash is allowed to collide. A shared bucket does not make the points equal: the equality check still separates them.

Two questions, one shared bucket

Read the steps as text

  1. Point a and Point b both hold x 2 and y 3, and Point c holds x 3 and y 2. The table has eight empty buckets, and the teaching hash adds the fields.
  2. Comparing Point a with Point b by identity gives false: they are two separate objects. Asking whether Point a equals Point b gives true: every field matches. Same fields, separate objects, so the two questions have different answers.
  3. The program hashes Point c to 5, which selects bucket 5, and stores it there. Only the bucket number was used, so nothing was compared yet.
  4. The program hashes Point a to 5, which selects bucket 5, and stores it there, beside Point c. Different values can share a bucket, because a hash is allowed to collide.
  5. The lookup hashes Point b to 5 and visits bucket 5. Point c is not equal to Point b. Point a is equal to Point b, so the lookup finds it. Equality, not the shared bucket, decided which entry matched.
  6. A hash picks the bucket to search, and equality picks the entry inside it.

Skip hashCode and the failure is concrete. The inherited hash is identity-based, so two equal values almost always get different hashes (the scene shows 11 and 6; real identity hashes are arbitrary numbers), the lookup visits a different bucket, and it never gets as far as asking equality:

final class Pin {
    final int code;
    Pin(int code) { this.code = code; }
    @Override public boolean equals(Object o) { return o instanceof Pin p && p.code == code; }
}

var pins = new java.util.HashSet<Pin>();
pins.add(new Pin(1));
System.out.println(pins.contains(new Pin(1))); // false in practice

Equal pins, different buckets

Read the steps as text

  1. Pin p1 and Pin p2 hold the same code. The class overrides equals but not hashCode, so each keeps its own inherited hash, shown as 11 and 6 here. A bucket is the hash's remainder after dividing by 8.
  2. The program hashes Pin p1 to 11, which selects bucket 3, and stores it there. The hash came from the object itself, not from its code.
  3. Asking whether Pin p1 equals Pin p2 gives true: every field matches. By value the two pins are the same pin.
  4. The lookup hashes Pin p2 to 6 and visits bucket 6. Bucket 6 is empty, so the lookup for Pin p2 misses without asking equality. Equal values were hashed to different buckets, so the stored Pin was never reached.
  5. Equals without a matching hashCode lets an equal value slip past its own entry.

Do not edit a key beneath its collection

Consider a mutable Ticket whose equality and hash use number. In the table model of this lesson, ticket 17 is stored in bucket 1 (17 mod 8). Change its number to 42 without relocating the entry. A lookup with that ticket now hashes 42, goes to bucket 2, and finds nothing. A fresh Ticket(17) goes to bucket 1, but the stored entry no longer compares equal to it, so that lookup misses too. The object was not removed; the key's location and meaning drifted apart. The miss is guaranteed; the bucket picture is only a model, and real collections lay out entries differently.

A key edited after storing

Read the steps as text

  1. Ticket t, Ticket f and Ticket g all hold number 17. Both equality and the hash read number, and the table is empty. The bucket is the remainder of the hash divided by 8.
  2. The program hashes Ticket t to 17, which selects bucket 1, and stores it there. The number 17 placed the entry.
  3. The program changes the number of Ticket t from 17 to 42. Nothing moved, so the entry sits in bucket 1 while its number says 42.
  4. The lookup hashes Ticket t to 42 and visits bucket 2. Bucket 2 is empty, so the lookup for Ticket t misses without asking equality. The new number points at a different bucket.
  5. The lookup hashes Ticket f to 17 and visits bucket 1. Ticket t is not equal to Ticket f. No entry in bucket 1 is equal to Ticket f, so the lookup misses. The old number reaches the entry's bucket, but equality no longer matches.
  6. The program changes the number of Ticket t from 42 to 17. The old number is back, so the hash points at bucket 1 again.
  7. The lookup hashes Ticket g to 17 and visits bucket 1. Ticket t is equal to Ticket g, so the lookup finds it. With the number back at 17, a fresh lookup finds it.
  8. The entry was present the whole time, and the edit hid it from lookups.
final class Ticket {
    int number;
    Ticket(int number) { this.number = number; }
    @Override public boolean equals(Object other) {
        return other instanceof Ticket t && number == t.number;
    }
    @Override public int hashCode() { return Integer.hashCode(number); }
}

var map = new java.util.HashMap<Ticket, String>();
var ticket = new Ticket(17);
map.put(ticket, "open");
ticket.number = 42;       // equality input changed while stored
System.out.println(map.get(ticket));          // null
System.out.println(map.get(new Ticket(17)));  // null
System.out.println(map.size());               // 1: still stored
class Ticket:
    def __init__(self, number):
        self.number = number

    def __eq__(self, other):
        return isinstance(other, Ticket) and self.number == other.number

    def __hash__(self):
        return hash(self.number)

ticket = Ticket(17)
records = {ticket: "open"}
ticket.number = 42       # equality input changed while stored
print(records.get(ticket))          # None
print(records.get(Ticket(17)))      # None
print(len(records))                 # 1: still stored

The safer choices are to build equality and hash from the same fields, and to keep those fields immutable or remove a key before changing them. In Python, a class that defines __eq__ without __hash__ gets __hash__ = None, so it cannot be a dict key or set member:

class Mark:
    def __init__(self, n): self.n = n
    def __eq__(self, other): return isinstance(other, Mark) and self.n == other.n

try:
    {Mark(1)}
except TypeError as err:
    print(err)  # unhashable type: 'Mark'

A @dataclass with eq=True and frozen=False does the same; unsafe_hash=True forces a hash and should be a deliberate exception. Records and @dataclass(frozen=True) generate equality and a matching hash together. Java has no automatic equivalent, so its class author must state and enforce any policy that values of the type are not used as hash keys.

When a type should not be a key

A value whose equality inputs must change cannot promise a stable hash. A Python type that sets __hash__ = None refuses the hash path entirely, which is the safer policy for mutable values.

A value with no hash

Read the steps as text

  1. Mark m1 and Mark m2 hold the same n. The class defines equality but sets its hash to None, so neither value has a hash.
  2. Asking whether Mark m1 equals Mark m2 gives true: every field matches. Equality works without a hash.
  3. Mark m1 has no hash, so the table cannot choose a bucket and refuses it. With no hash there is no bucket, so Python refuses the key.
  4. The lookup for Mark m2 cannot start: with no hash it has no bucket to visit. A lookup needs a bucket too, so it is refused as well.
  5. An unhashable value still compares equal, but it cannot be a set member or dict key.

Recap

  • Identity asks whether two names reach one object; value equality asks whether a type treats two values as equivalent.
  • Equal values must have equal hashes, but unequal values may collide and still need an equality check. A Java class that overrides equals without hashCode misses its own equal values in a HashSet.
  • Keep equality inputs stable while a key is stored.
  • In Python, choose __hash__ = None when mutable equality inputs should not be hashable. Java must enforce stable inputs or keep the type out of hash collections through its API and design policy.
Go deeper

Java's equals contract is reflexive, symmetric, transitive, consistent, and false for null; these properties make equality usable as a collection decision rather than a one-off comparison. Python's __eq__ can return NotImplemented for an unrelated type, allowing the other operand or the final fallback to participate. Neither detail changes the key invariant: the equality relation used by a collection and the hash function must agree on every pair the collection can compare.

Records and frozen dataclasses are compact value examples, not a promise that every field is recursively immutable. If a value contains a mutable list, that nested object can still change the equality inputs. The next lesson makes that ownership boundary explicit before a value crosses an API.

Continue to immutable value boundaries when a value object contains a list or other mutable descendant. Practise the same equality distinction in Linked List Fundamentals, where equal-looking node values do not make two nodes identical.