Objects and classes6 min · 4 of 16

Identity, equality and value semantics

Tell apart same object, same value and same key, then write equals and hashCode as one contract so sets and dictionaries keep working.

Three different questions get asked with the same two characters. Is this the same object as that one? Does it carry the same value? And will a dictionary find it again tomorrow? Most equality bugs are one of those questions answered with another one's operator.

Same object, same value

Python separates them by keyword. a is b compares identity — the address, what id() returns. a == b calls __eq__, which by default falls back to identity, so a class with no __eq__ is equal only to itself.

a = "hello world"
b = "hello world"
a == b        # True — same value
a is b        # sometimes True, because CPython interns some literals

Java splits the same two ideas across == and .equals(): == on references is identity, and Object.equals is identity too until you override it. So both languages default to "two objects are different unless they are the same object", and value semantics are something you opt into.

The interning cases are where people get burned. CPython caches small integers and some strings, and Java caches boxed Integer values from −128 to 127, so Integer.valueOf(100) == Integer.valueOf(100) is true and Integer.valueOf(200) == Integer.valueOf(200) is false. Nothing about the numbers changed; the cache ran out. Use .equals for values and reserve == and is for the question "is this literally the same object", which in practice means comparing against None / null or checking aliasing.

Value objects and entities

Two 500-paise amounts in rupees are the same money; there is no second fact that distinguishes them. Two customers called Priya are two customers. That is the whole distinction:

  • A value object has no life outside its fields. Equality is over all of them. Money, DateRange, a coordinate.
  • An entity has an identity that survives changes to its fields. A customer who moves house is the same customer. Equality is over the id alone.

Deciding which one you have comes before writing either method, and it is the same act as listing the fields a class owns in state and behaviour: the fields that define the object, against the fields it merely carries.

Both languages will write a value object for you:

from dataclasses import dataclass

@dataclass(frozen=True)
class Money:
    paise: int
    currency: str
public record Money(long paise, String currency) {}

frozen=True generates __eq__ and __hash__; a Java record generates equals, hashCode and toString. Leave off frozen and the dataclass still generates __eq__ but sets __hash__ to None, making instances unhashable — a deliberate choice by the language, and the right one, for the reason below.

The contract, and what breaks without it

Equal objects must have equal hash codes. The converse is not required: unequal objects may collide, which is what a bucket is for. Break the rule in one direction and every hash-based container quietly stops working.

Python refuses to let you: defining __eq__ without __hash__ sets __hash__ to None, and the first attempt to put an instance in a set raises TypeError: unhashable type. Java says nothing at all.

final class Money {
    private final long paise;
    Money(long paise) { this.paise = paise; }
    @Override public boolean equals(Object o) {
        return o instanceof Money m && m.paise == paise;
    }
    // no hashCode
}

Set<Money> seen = new HashSet<>();
seen.add(new Money(500));
seen.contains(new Money(500));   // false

Object.hashCode is derived from identity, so the two equal objects land in different buckets and equals is never reached. No exception, no stack trace, a wrong answer.

The size of what you gave up is worth stating. A hash set exists to replace a scan: 1 million entries at roughly one main memory reference each, 1,000,000 × 100 ns = 0.1 s per lookup, becomes a hash plus a probe at roughly 100 ns — a factor of a million, the same kind of jump as the one counted in complexity by counting. Break the contract and you keep paying for the hashing and lose the correctness.

Mutating a key that is already in a set

The second failure is worse, because the object is genuinely in the container:

class Tag:
    def __init__(self, name): self.name = name
    def __eq__(self, other):
        if not isinstance(other, Tag):
            return NotImplemented
        return self.name == other.name
    def __hash__(self): return hash(self.name)

t = Tag("draft")
s = {t}
t.name = "final"

t in s              # False
len(s)              # 1
[x.name for x in s] # ['final'] — it is right there

The guard is not decoration. Without it, self.name == other.name reaches for a field the other object may not have, so Tag("draft") == None raises AttributeError instead of answering False, and a single tag in mixed_list takes the process down. Return NotImplemented, not False: it tells Python to offer the comparison to the right-hand operand's __eq__ before falling back to identity, which is what lets a Tag subclass answer for itself. Java's version above gets this for free from o instanceof Money m, which is false for null and for every unrelated type.

The set filed the object once. Changing the field changed the address, and nothing went back to move it.
A tag filed in bucket 2 under its old name is looked for in bucket 5 after the name changes, so the set contains an object it cannot findbuckets012345name=finaladded while name wasdraftt in s hashes final andlooks hereemptythe hash moved from 2 to 5The object is in the set, len(s) is 1, and no lookup will ever reach itagain.

Scroll to zoom · drag to pan · 0 fits · Esc closes

Java behaves identically with a HashSet, for the identical reason. C++ removes the option: std::unordered_set hands out only const references to its keys, so the standard makes modifying one either impossible or undefined rather than silently wrong.

The rule that falls out of the diagram is short. Anything used as a key must be immutable, or must hash on only the parts that never change. For a value object that means @dataclass(frozen=True) or a record with final fields — built once, checked once, in the manner of constructors and invariants. For an entity it means hashing the id and nothing else, so a customer can change address without escaping the map she is stored in.

In an interview

You are being tested on two things: whether you know equals and hashCode are one contract rather than two methods, and whether you classify the type before writing either.

Say the classification out loud. "Money is a value object, so equality is over all fields, the hash comes from the same fields, and the type is immutable, which means the hash cannot go stale. Customer is an entity, so equality is the customer id only and changing the address changes nothing about identity."

The mistake that loses points: writing equals over every field of a mutable entity and then using it as a map key. It works in the test, works in the demo, and loses a record the first time production edits a field. The related slip is overriding equals and stopping there — if you write one, write both, and say why while you do it.

Check yourself

A Java class overrides equals but not hashCode. You add an object to a HashSet and call contains with an equal-but-distinct object. What comes back, and why?

false. Object.hashCode is identity-derived, so the two objects hash to different buckets, and the equals you wrote is never called. Nothing throws.

A nightly job checks 1 million Tag objects for membership against the 1 million already seen. You wrote __eq__ and no __hash__, so the tags sit in a list and membership is in. Estimate the run, estimate it again after you add __hash__ and switch to a set, and name the field you would hash.

A list in is a scan: 1 million entries at roughly one main memory reference each, 1,000,000 × 100 ns = 0.1 s per lookup, and 1 million lookups is 1,000,000 × 0.1 s = 100,000 s ≈ 28 hours — the job cannot finish overnight. A set makes each lookup a hash plus a probe at roughly 100 ns, so the pass is 1,000,000 × 100 ns = 0.1 s. The container is the easy half. The decision is which field the hash reads: hash name and the run is silently wrong the first time anything renames a tag mid-job, so hash an immutable id and let name be a field the object merely carries.

An Order holds an id, a customer, a status and a total, and its status changes as it is fulfilled. What should equals and hashCode use?

The id alone. Order is an entity: it is the same order before and after shipping, and hashing anything mutable would strand it in any map it was put into. If you want "same contents" as well, that is a separate named method, not equals.