Comparison rules
Identity, equality, and object comparison
Equality asks whether two references mean the same thing. Identity asks whether they are the same instance over time. Example: you put Ticket T-1042 in a HashSet, later update its spot, and the set can no longer find it. Or you write Money in two places, forget equals, and the gate thinks 20 rupees is not 20 rupees. Both bugs come from comparing the wrong thing.
When are two objects the same thing?
Equality follows the domain. Two Ticket objects are the same entity when their ids match. The spot, the fee, and the status can change. The id must not be part of a moving hash. Two Money objects are the same value when paise and currency match. There is no “this twenty” versus “that twenty.” Value objects should be immutable so equality stays stable.
and are a pair. If two objects are equal, they must hash the same. Put a mutable entity in a HashSet or HashMap key and then change a hashed field. The collection loses it. That is why entity equality uses a stable id, and value equality uses all of the value.
Default Object.equals is . That is wrong for Money. It is also the wrong story for Ticket if you later load the same id from a store and need it to match the in-memory one. Decide the rule. Write it next to the type. Do not let every caller invent ==.
The HashSet disappearing act
- T0
Ticket T-1042 hashes from id + spot S1. You add it to a HashSet.
- T1
The car is moved. You set spot to S2. hashCode now lands in a different bucket.
- T2
set.contains(ticket) looks in the new bucket. The object still sits in the old one.
- T3
The set “lost” a ticket that is still in memory. Occupancy and billing drift.
The fix is not “don’t use HashSet.” The fix is : hash the id only. The spot may move. The bucket must not.
Entities: id only
Same id, same entity. Stale copies with different fees still compare equal. That is usually what you want when a set means “these stays,” not “these snapshots.”
Values: every field
for Money includes paise and currency. Leave one out and 20 INR equals 20 USD in a sloppy implementation — or fails to match a second 20 INR.
Id for entities, fields for values
class Ticket {
private final TicketId id;
private ParkingSpot spot;
private Money fee;
@Override
public boolean equals(Object o) {
return o instanceof Ticket t && id.equals(t.id);
}
@Override
public int hashCode() {
return id.hashCode(); // never spot or fee
}
}
final class Money {
private final long paise;
private final String currency;
@Override
public boolean equals(Object o) {
return o instanceof Money m
&& paise == m.paise
&& currency.equals(m.currency);
}
@Override
public int hashCode() {
return Objects.hash(paise, currency);
}
}The contract you must not break
Java’s collection types assume a few sentences. Reflexive: x.equals(x). Symmetric: if a equals b, b equals a. Transitive: if a equals b and b equals c, a equals c. Consistent: the answer does not change unless a defining field changes. And the hash pair: equal objects share a hash. Break any of these and HashMap becomes folklore.
Inheritance makes symmetry easy to break. If Money.equals accepts a subclass that adds a “source system” field, parent-to-child and child-to-parent can disagree. Prefer final value types. For entities, prefer comparing ids, not a fragile field list that grows with every feature.
Safe as a map key
- TicketId, Money, Email — immutable values
- Ticket if equals/hashCode use only id
- Enums: SpotType, TicketStatus
Unsafe as a map key
- Ticket hashed by status or spot
- Mutable Date / Calendar inside equals
- Arrays compared with == instead of Arrays.equals
Two mistakes that look like “Java trivia”
Hashing a field that still moves: Ticket.hashCode() from status or spot will break HashSet the moment the car is reassigned. Identity must be stable. Using == for Money: two new Money(2000, "INR") values are the same twenty rupees. Reference equality says they are not. Value types need equals.
This is the same distinction as entity versus value object. Comparison is how that distinction becomes code. In Splitwise, two Expense objects with the same total are not the same dinner. Two Money objects with the same paise are the same share.
A test that documents the rule
You do not need a framework sermon. Three assertions next to the type save the next person from “improving” equals to include the spot.
@Test
void ticketEqualityIgnoresSpot() {
Ticket a = Ticket.issue(new TicketId("T-1042"), spotA, vehicle);
Ticket b = Ticket.issue(new TicketId("T-1042"), spotB, vehicle);
assertEquals(a, b);
Set<Ticket> open = new HashSet<>();
open.add(a);
a.reassign(spotB);
assertTrue(open.contains(a));
}
@Test
void moneyEqualityIsByContent() {
assertEquals(Money.inr(20, 0), new Money(2000, "INR"));
assertNotEquals(Money.inr(20, 0), Money.usd(20, 0));
}What about compareTo?
Sorted sets need an ordering that agrees with equals. If Money.compareTo uses only paise and equals uses paise plus currency, TreeSet will treat 20 INR and 20 USD as the same node. Either include currency in both, or do not put mixed currencies in one sorted collection. Duration is easier: minutes are a total order. Tickets should almost never be TreeSet keys by fee. Fee changes. Id does not.
Loaded from a store, still the same stay
In-memory machine coding still hits this. You issue a ticket, put it in a map, then later “load” a second Ticket instance with the same id for exit. If equals is reference equality, the map lookup fails and you invent a second stay. If equals is identity equality on TicketId, exit finds the original. That is why the rule belongs on the type, not in the controller that happened to keep the first reference.
Persistence frameworks will also surprise you if you mix both styles. A first-level cache may return the same instance. A DTO mapper will not. Design as if two instances can represent one entity. Then both paths work.
Null, enums, and “almost values”
equals should reject null and reject a different type. Do not return true for a Ticket and a TicketId that happen to share a string. Enums already have identity-and-value collapsed: there is one COMPACT. Use them for closed sets. Do not invent a SpotType class with a mutable name field and then wonder why two COMPACT instances disagree.
A HashMap that tells the truth
Machine-coding often uses Map<TicketId, Ticket> instead of hashing Ticket itself. That is a good instinct: the key is already a stable value. Do not then also put Ticket in a HashSet hashed by spot “for convenience.” One rule per type. If the map key is TicketId, Ticket.equals can still be identity equality for when two instances meet. They will not fight.
class Gate {
private final Map<TicketId, Ticket> open = new HashMap<>();
void enter(Ticket ticket) {
open.put(ticket.id(), ticket);
}
Ticket leave(TicketId id) {
Ticket ticket = open.remove(id);
if (ticket == null) throw new IllegalArgumentException("unknown ticket");
return ticket;
}
}What you write on the board
“Ticket equals by id. Money equals by paise and currency. I will not hash a field that still moves.” Then implement both pairs. If the interviewer later asks you to put tickets in a set, you already know the set will survive a reassignment. That is the whole page, said in one breath.