Java Bridge · J6
equals, hashCode, toString, compareTo — and the == trap
- Java Bridge
- 8 min read
- after J1
The idea
== compares references, and no compiler will warn you
The lot finds a parked car's ticket from its plate, so Registration becomes a HashMap key. In C++ that class earns value semantics from operator==, and you write one. Java has no operator overloading, so there is nothing of that shape to write.
The first version therefore has no equals at all, and the lookup misses:
contains(equal plate) : false
map.get(equal plate) : null
The plate is in the list. == on objects always means "same reference", and Object.equals means the same thing. Nothing failed to compile and no warning printed, because equals was inherited rather than absent. That is the difference which costs hours: in C++ the compiler stops you at the comparison site, and here there is nothing to stop.
Add an equals comparing the two codes, then run it again:
contains(equal plate) : true
map.get(equal plate) : null
The list is right and the map is wrong, from one object in one program. HashMap compares hash codes before it calls equals, so equal objects with unequal hashes are never considered. This version is worse than the first, because a code review and a list-based test both pass it.
hashCode, derived from the same field equals compares, is the other half. Override one and you owe the other.
Two further methods replace operators C++ handed you. toString for anything you will read in a log, since the default prints LooseRegistration@1c20c684, and compareTo for ordering, since there is no operator<.
Coming from C++
From C++ — the four operators that became four methods
In C++ you overload operators and value comparison works everywhere. Java has no operator overloading, and the replacements are ordinary methods that the standard library calls by name. Fail to write them and the library calls Object's versions instead, which answer a different question.
operator== becomes equals(Object)
C++
class Registration {
std::string code_;
public:
explicit Registration(std::string code) : code_(std::move(code)) {}
bool operator==(const Registration& other) const { return code_ == other.code_; }
};
Registration a{"KA01AB1234"};
Registration b{"KA01AB1234"};
a == b; // true
std::vector<Registration> v{a};
std::find(v.begin(), v.end(), b) != v.end(); // true
Java
public final class Registration {
private final String code;
public Registration(String code) { this.code = code; }
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof Registration that)) return false;
return code.equals(that.code);
}
}
Registration a = new Registration("KA01AB1234");
Registration b = new Registration("KA01AB1234");
a == b; // false, always, and no override can change it
a.equals(b); // true, because of the method above
| C++ | Java |
|---|---|
a == b calls your operator== | a == b compares references. Not overridable |
Omit operator== and a == b is a compile error | Omit equals and a.equals(b) compiles, returning a == b |
const Registration& parameter | Object parameter. Narrowing it makes an overload, not an override |
std::find uses operator== | List.contains uses equals |
The two rows that cost hours. In C++ the compiler stops you at the comparison site when operator== is missing. In Java there is nothing to stop, because Object already declares equals. So the missing method is not a compile error — it is a lookup that returns the wrong answer.
And the parameter type is Object, not Registration. The natural C++ translation is public boolean equals(Registration other), which compiles and is a different method:
Plate p = new Plate("KA01AB1234");
Plate q = new Plate("KA01AB1234");
p.equals(q); // true — the compiler picked your overload
list.contains(q); // false — List called Object.equals
p.equals((Object) q); // false — same
Verified output from that exact code, compiled with -Xlint:all, zero warnings. @Override converts it into a compile error:
Registration.java:28: error: method does not override or implement a method from a supertype
@Override
^
That is the reason to write @Override on every equals you ever type.
std::hash specialisation becomes hashCode()
C++ — a separate opt-in, and the compiler makes you do it
namespace std {
template <> struct hash<Registration> {
size_t operator()(const Registration& r) const { return hash<string>{}(r.code()); }
};
}
std::unordered_map<Registration, std::string> tickets; // fails to compile without the above
Java — already inherited, already wrong
@Override
public int hashCode() {
return code.hashCode();
}
Map<Registration, String> tickets = new HashMap<>(); // compiles either way
| C++ | Java |
|---|---|
No std::hash specialisation, no unordered_map — compile error | HashMap accepts any key. Object.hashCode is inherited |
operator== and hash are separately declared, both required | equals and hashCode are separately overridable, and only convention links them |
Object.hashCode returns a per-instance identity value. So an equals without a matching hashCode gives you two equal objects with different hashes, and HashMap.getNode compares hashes before it calls equals. The entry is never considered. Real output, one entry in the map:
--- 3. equals without hashCode: the list works, the map does not
contains(equal plate) : true
map.get(equal plate) : null
set size after 2 equal adds: 2
The list is right and the map is wrong, from the same object. This is worse than forgetting equals entirely, because a review and a list-based test both pass.
Plain javac says nothing. javac -Xlint:overrides does:
warning: [overrides] Class HalfEqualRegistration overrides equals, but neither it nor any
superclass overrides hashCode method
operator<< becomes toString()
C++
std::ostream& operator<<(std::ostream& os, const Registration& r) {
return os << "Registration[" << r.code() << "]";
}
Java
@Override
public String toString() {
return "Registration[" + code + "]";
}
Without it, Object.toString returns the class name plus @ plus the hex identity hash:
no override : LooseRegistration@1c20c684
override : Registration[KA01AB1234]
The hex changes between runs, so it is worse than useless in a log or an assertion failure. Unlike operator<<, toString is called implicitly by string concatenation, println, and every JUnit failure message — so overriding it changes the output of code you did not write.
operator< becomes compareTo, or a Comparator
C++ — one operator serves sorting and ordered containers
bool operator<(const Registration& a, const Registration& b) { return a.code() < b.code(); }
std::sort(v.begin(), v.end()); // uses operator<
std::map<Registration, std::string> byPlate; // uses operator<
std::sort(v.begin(), v.end(), byLengthThenCode); // or an explicit comparator
Java — two mechanisms, and they mean different things
public final class Registration implements Comparable<Registration> {
@Override
public int compareTo(Registration other) {
return code.compareTo(other.code);
}
}
plates.sort(null); // natural order, via compareTo
plates.sort(Comparator.comparing(Registration::code).reversed()); // one alternative order
Map<Registration, String> byPlate = new TreeMap<>(); // needs Comparable, or a Comparator
| C++ | Java |
|---|---|
operator<, returning bool | compareTo, returning negative/zero/positive int |
| Free function or member, your choice | Implementing Comparable<T> on the class |
A comparator functor passed to std::sort | A Comparator<T> passed to sort or to TreeMap |
std::map needs a strict weak ordering | TreeMap needs Comparable, or throws at run time |
Comparable is the type's one natural order. Comparator is every other order, and it lives outside the type — which is how you sort by three different keys without touching the class.
Omit Comparable and a TreeMap fails at run time, not compile time:
ClassCastException: class LooseRegistration cannot be cast to class java.lang.Comparable
(LooseRegistration is in unnamed module of loader 'app'; java.lang.Comparable is in module
java.base of loader 'bootstrap')
Note where the failure lands: on the first put, not at the new TreeMap<>(), and not at compile time. TreeMap<K, V> places no extends Comparable bound on K, because a Comparator is the other legal way to supply the order.
compareTo must agree with equals. TreeMap uses compareTo alone and never calls equals. So a compareTo returning 0 for two objects that are not equals gives you a TreeMap and a HashMap that disagree about whether you have one key or two.
The shortcut: record
The corpus contract uses one:
public record Vehicle(String registration, VehicleType type) {}
A record generates equals, hashCode and toString from its components, correctly and consistently. It generates no compareTo. So the four-method drill above applies whenever you write a class rather than a record. That means whenever the type needs mutable state, extra invariants, or identity semantics. record is J11; the reason to know it now is that it removes three of the four traps on this page.
Worked walkthrough
NOTES — three versions of one value type, and what each one breaks
Run it first
.toolchain\jdk-21\bin\javac.exe -d out *.java
.toolchain\jdk-21\bin\java.exe -cp out Main
Real output, verbatim:
--- 1. == on two objects with the same value
a == b : false
a.equals(b) : true
--- 2. no equals at all: the list lookup that misses
size : 1
contains(equal plate) : false
map.get(equal plate) : null
--- 3. equals without hashCode: the list works, the map does not
contains(equal plate) : true
map.get(equal plate) : null
set size after 2 equal adds: 2
--- 4. equals and hashCode together: every lookup lands
contains(equal plate) : true
map.get(equal plate) : T-9001
set size after 2 equal adds: 1
--- 5. toString: default versus overridden
no override : LooseRegistration@1c20c684
override : Registration[KA01AB1234]
--- 6. ordering without operator<
natural order : [Registration[KA01AB1234], Registration[KA03CD5678], Registration[KA05ZZ0002]]
reversed : [Registration[KA05ZZ0002], Registration[KA03CD5678], Registration[KA01AB1234]]
TreeMap keys : [Registration[KA01AB1234], Registration[KA03CD5678], Registration[KA05ZZ0002]]
--- 7. a TreeMap key that is not Comparable
ClassCastException: class LooseRegistration cannot be cast to class java.lang.Comparable (LooseRegistration is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')
The hex in LooseRegistration@1c20c684 is an identity hash and changes between JVM runs. Every other line is deterministic, including the two nulls and the set size of 2.
Read block 3 twice. contains says the plate is there and get says it is not, for the same object, in the same program. That is the shape of the bug this lesson exists to prevent, and it is the one that reaches production, because a list-based test passes.
Registration.java — the version that works
public final class Registration implements Comparable<Registration> {
private final String code;
final on the class as well as the field. A subclass could override equals and break symmetry: sub.equals(base) true while base.equals(sub) false. HashMap then behaves differently depending on which object it happens to hold, which is a bug with no reliable reproduction. Sealing the class removes the possibility. The alternative, getClass() != other.getClass() inside equals, is the other accepted answer and is what an interviewer may ask you to compare it to.
public Registration(String code) {
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("registration code must not be blank");
}
this.code = code;
}
The null check is load-bearing for hashCode. hashCode delegates to code.hashCode(), which would throw NullPointerException on a null field. Rejecting null in the constructor means no Registration exists that cannot be hashed. That invariant is what A5 calls "no invalid instance can exist", and here it is the difference between a clean exception at construction and a NullPointerException from inside HashMap.put.
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
The parameter is Object. Narrow it to Registration and you have written an overload that the whole standard library ignores. @Override is what makes that a compile error rather than a silent wrong answer — see experiment 1 below.
this == other is a fast path, not a correctness requirement. It matters because HashMap.getNode calls equals after finding a bucket match, and in the common case the key found is the key given. Removing this line keeps the code correct and slower.
if (!(other instanceof Registration that)) {
return false;
}
One line covers null and wrong-type. instanceof is false for null, so no separate null check is needed. The that on the end is a pattern variable, in scope only where the test succeeded, and it removes the cast that would otherwise throw ClassCastException.
The contract equals must satisfy: reflexive, symmetric, transitive, consistent, and x.equals(null) false. Symmetry is the one people break, and instanceof against a non-final class is the usual way they break it.
@Override
public int hashCode() {
return code.hashCode();
}
Derived from exactly the fields equals compares. The contract runs one way only: equal objects must have equal hashes; unequal objects may share a hash. Break it and every hash collection breaks, because HashMap compares hashes first and never reaches equals.
Objects.hash(code) is the usual form for several fields and is correct here too. For one field it allocates a varargs array on every call, which is measurable when a key is hashed inside a hot loop. Two fields or more: Objects.hash(a, b).
Nothing requires the hash to be unique or well distributed. A hashCode returning 0 for every instance is legal, correct, and turns every HashMap operation into a linear scan.
@Override
public String toString() {
return "Registration[" + code + "]";
}
This changes the output of code you did not write. String concatenation, println, and every JUnit failure message call toString implicitly. Compare the two lines in block 5: the default tells you nothing, and it is what a failing assertion will show you at 1am.
@Override
public int compareTo(Registration other) {
return code.compareTo(other.code);
}
Delegating to String.compareTo gets the sign convention right for free. The contract is negative, zero, or positive — never specifically -1 and 1. Writing return this.code.length() - other.code.length() is the classic bug: correct for short strings, and wrong on overflow for large int values.
compareTo must agree with equals. TreeMap uses compareTo alone and never calls equals. A compareTo returning 0 for objects that are not equals therefore gives you a TreeMap and a HashMap that disagree about how many keys you have.
LooseRegistration.java — no equals, no hashCode
The whole class is a field, a constructor and an accessor. It is what a C++ developer writes first, because in C++ the missing operator== would be a compile error at the comparison site.
Here equals is inherited from Object, where it is defined as this == other. So:
size : 1
contains(equal plate) : false
map.get(equal plate) : null
The list holds one element, and contains says it does not hold an equal one. Both statements are true, because "equal" means "the same object" until you say otherwise.
The invariant this class fails to hold: two registrations with the same code are interchangeable. Every lookup in the parking lot depends on it — find a ticket from a plate, check whether a vehicle is already parked, count distinct vehicles.
HalfEqualRegistration.java — equals without hashCode
contains(equal plate) : true
map.get(equal plate) : null
set size after 2 equal adds: 2
contains works and get does not, deterministically. Not "usually" and not "depending on timing". HashMap.getNode reads:
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
The hash comparison comes first and short-circuits. Two equal objects with different identity hashes never reach the equals call, whatever bucket they land in. Same reason the HashSet holds two elements it considers equal.
Plain javac prints nothing. javac -Xlint:overrides prints this:
HalfEqualRegistration.java:6: warning: [overrides] Class HalfEqualRegistration overrides equals, but neither it nor any superclass overrides hashCode method
public final class HalfEqualRegistration {
^
1 warning
-Xlint:all includes it. Adding that flag once is the cheapest defence against the only mistake here a compiler can see.
Two experiments, run for real
1 · The equals overload that is not an override
Write the C++-shaped signature, with no @Override:
public final class Plate {
private final String code;
public Plate(String code) { this.code = code; }
public boolean equals(Plate other) { return code.equals(other.code); }
@Override public int hashCode() { return code.hashCode(); }
}
Compiled with -Xlint:all. Zero warnings. Then:
p.equals(q) directly : true
list.contains(q) : false
p.equals((Object) q) : false
The first line is your overload, chosen by the compiler because the static type is Plate. The second and third are Object.equals, chosen because List.contains holds Object references. So the method works everywhere you call it directly and nowhere the library calls it.
Add @Override and the file stops compiling:
Registration.java:28: error: method does not override or implement a method from a supertype
@Override
^
1 error
That is the entire argument for writing @Override on equals every time.
2 · A TreeMap key with no Comparable
new TreeMap<>() compiles for any key type, because supplying a Comparator is the other legal way to give an order. The failure lands on the first put:
ClassCastException: class LooseRegistration cannot be cast to class java.lang.Comparable (LooseRegistration is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')
Read the message past the module noise: the key type is not Comparable. Two fixes, and they mean different things. implements Comparable<Registration> declares the type's one natural order. new TreeMap<>(comparator) supplies an order for this map only, and leaves the type alone.
Which type to write
Reach for a record first:
public record Vehicle(String registration, VehicleType type) {}
That generates equals, hashCode and toString from the components, and none of this lesson's traps apply. It generates no compareTo.
Write the four methods by hand when a record will not do the job: the type has mutable state, or identity semantics are what you actually want. A ParkingLot is not a value and should have no equals — two lots with equal contents are not the same lot. Overriding equals on an entity is its own bug, and the tell is that the type has a lifecycle rather than a value.
Worked source
The 4 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/HalfEqualRegistration.java37 linesworked/src/LooseRegistration.java18 linesworked/src/Registration.java80 linesworked/src/Main.java107 lines
worked/src/HalfEqualRegistration.java37 lines
// HalfEqualRegistration.java
//
// equals overridden, hashCode not. This is the more dangerous of the two mistakes, because
// list.contains works and only the hash collections lie. Code review passes, unit tests on
// lists pass, and the HashMap lookup misses in production.
public final class HalfEqualRegistration {
private final String code;
public HalfEqualRegistration(String code) {
this.code = code;
}
public String code() {
return code;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof HalfEqualRegistration that)) {
return false;
}
return code.equals(that.code);
}
// hashCode is deliberately absent. Object.hashCode gives a per-instance identity value,
// so two equal HalfEqualRegistrations report different hashes. HashMap compares hashes
// before it calls equals, so the entry is never considered.
//
// Plain javac is silent about this. `javac -Xlint:overrides` is not:
// warning: [overrides] Class HalfEqualRegistration overrides equals, but neither it
// nor any superclass overrides hashCode method
// Worth adding to your build once, because it is the one mistake here a compiler can see.
}
worked/src/LooseRegistration.java18 lines
// LooseRegistration.java
//
// The same type with equals and hashCode left out. This is what you get by writing the class
// and stopping, and it is what a C++ developer does not expect: in C++ you would reach for
// operator==, and its absence is a compile error at the comparison site. Here the comparison
// compiles and answers the wrong question.
public final class LooseRegistration {
private final String code;
public LooseRegistration(String code) {
this.code = code;
}
public String code() {
return code;
}
}
worked/src/Registration.java80 lines
// Registration.java
//
// A vehicle registration, used as a HashMap key so the lot can find a parked car's ticket
// from its plate. Four methods make that work, and Java generates none of them for a class.
public final class Registration implements Comparable<Registration> {
private final String code;
public Registration(String code) {
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("registration code must not be blank");
}
this.code = code;
}
public String code() {
return code;
}
/**
* Two registrations are equal when their codes are equal. Without this method, `equals`
* is inherited from Object, where it means `==` — reference identity. Then
* list.contains(new Registration("KA01AB1234")) returns false with that plate in the list.
*
* The parameter is Object, not Registration. Narrow it and you have written an overload,
* not an override, and every collection keeps calling Object's version.
*/
@Override
public boolean equals(Object other) {
// Identity fast path. Not required for correctness; it short-circuits the common
// map-lookup case where the key found is the key given.
if (this == other) {
return true;
}
// instanceof is false for null, so this one line covers both the null case and the
// wrong-type case. A cast without it throws ClassCastException.
if (!(other instanceof Registration that)) {
return false;
}
return code.equals(that.code);
}
/**
* Derived from exactly the field equals() compares. That is the contract: equal objects
* must return equal hash codes.
*
* Break it and every hash collection breaks with it. HashMap.getNode compares hashes
* before it calls equals, so a wrong hash means the entry is never even considered.
*/
@Override
public int hashCode() {
// One field, so delegate. Objects.hash(code) also works and allocates a varargs
// array on every call — measurable when a key is hashed inside a hot loop.
return code.hashCode();
}
/**
* Object.toString() returns getClass().getName() + "@" + hex identity hash, which tells
* you nothing in a debugger or an assertion failure. Override it on every type you will
* read in a log.
*/
@Override
public String toString() {
return "Registration[" + code + "]";
}
/**
* Java has no operator overloading. compareTo is the replacement for operator<, and
* implementing Comparable is what lets a Registration be a TreeMap key or be sorted by
* Collections.sort with no comparator.
*
* The contract: consistent with equals, so compareTo returns 0 exactly when equals
* returns true. TreeMap uses compareTo alone and ignores equals entirely, so an
* inconsistent pair gives you two collections that disagree about duplicates.
*/
@Override
public int compareTo(Registration other) {
return code.compareTo(other.code);
}
}
worked/src/Main.java107 lines
// Main.java
//
// Runs the trap and prints what actually happens. Every line of output in NOTES.md came from
// this file.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
public class Main {
private static final String PLATE = "KA01AB1234";
public static void main(String[] args) {
System.out.println("--- 1. == on two objects with the same value");
Registration a = new Registration(PLATE);
Registration b = new Registration(PLATE);
System.out.println("a == b : " + (a == b));
System.out.println("a.equals(b) : " + a.equals(b));
System.out.println();
System.out.println("--- 2. no equals at all: the list lookup that misses");
List<LooseRegistration> parkedLoose = new ArrayList<>();
parkedLoose.add(new LooseRegistration(PLATE));
System.out.println("size : " + parkedLoose.size());
System.out.println("contains(equal plate) : "
+ parkedLoose.contains(new LooseRegistration(PLATE)));
Map<LooseRegistration, String> ticketsLoose = new HashMap<>();
ticketsLoose.put(new LooseRegistration(PLATE), "T-9001");
System.out.println("map.get(equal plate) : "
+ ticketsLoose.get(new LooseRegistration(PLATE)));
System.out.println();
System.out.println("--- 3. equals without hashCode: the list works, the map does not");
List<HalfEqualRegistration> parkedHalf = new ArrayList<>();
parkedHalf.add(new HalfEqualRegistration(PLATE));
System.out.println("contains(equal plate) : "
+ parkedHalf.contains(new HalfEqualRegistration(PLATE)));
Map<HalfEqualRegistration, String> ticketsHalf = new HashMap<>();
ticketsHalf.put(new HalfEqualRegistration(PLATE), "T-9001");
System.out.println("map.get(equal plate) : "
+ ticketsHalf.get(new HalfEqualRegistration(PLATE)));
Set<HalfEqualRegistration> halfSet = new HashSet<>();
halfSet.add(new HalfEqualRegistration(PLATE));
halfSet.add(new HalfEqualRegistration(PLATE));
System.out.println("set size after 2 equal adds: " + halfSet.size());
System.out.println();
System.out.println("--- 4. equals and hashCode together: every lookup lands");
List<Registration> parked = new ArrayList<>();
parked.add(new Registration(PLATE));
System.out.println("contains(equal plate) : " + parked.contains(new Registration(PLATE)));
Map<Registration, String> tickets = new HashMap<>();
tickets.put(new Registration(PLATE), "T-9001");
System.out.println("map.get(equal plate) : " + tickets.get(new Registration(PLATE)));
Set<Registration> set = new HashSet<>();
set.add(new Registration(PLATE));
set.add(new Registration(PLATE));
System.out.println("set size after 2 equal adds: " + set.size());
System.out.println();
System.out.println("--- 5. toString: default versus overridden");
System.out.println("no override : " + new LooseRegistration(PLATE));
System.out.println("override : " + new Registration(PLATE));
System.out.println();
System.out.println("--- 6. ordering without operator<");
List<Registration> plates = new ArrayList<>(List.of(
new Registration("KA05ZZ0002"),
new Registration("KA01AB1234"),
new Registration("KA03CD5678")));
// Natural order. No comparator argument, because Registration is Comparable.
plates.sort(null);
System.out.println("natural order : " + plates);
// A different order, without touching Registration. Comparator is the replacement
// for passing a comparison functor to std::sort.
plates.sort(Comparator.comparing(Registration::code).reversed());
System.out.println("reversed : " + plates);
// TreeMap uses compareTo and never calls equals.
Map<Registration, String> byPlate = new TreeMap<>();
for (Registration r : plates) {
byPlate.put(r, "T-" + r.code().substring(2, 4));
}
System.out.println("TreeMap keys : " + byPlate.keySet());
System.out.println();
System.out.println("--- 7. a TreeMap key that is not Comparable");
try {
Map<LooseRegistration, String> broken = new TreeMap<>();
broken.put(new LooseRegistration(PLATE), "T-9001");
} catch (ClassCastException e) {
System.out.println("ClassCastException: " + e.getMessage());
}
}
}
The faded stage is not here, on purpose
In the app, the third stage of a lesson hands you the worked design with a few lines
replaced by // GAP: markers, then compiles your completion and runs a JUnit suite
against it. That needs javac, and a static site has no compiler — so rather than show a
control that cannot work, this page stops at the worked source.
Run the app for the drill: it is the download in the header, and it works offline once unpacked.