equals-hashcode: equals/hashCode contract demos, missing hashCode and constant-hash costs, mutable keys, records, JPA entity id trap, 13 contract tests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b66uwsWr9B1ouyrzUh
This commit is contained in:
@@ -29,6 +29,7 @@ article; each module's own README has that article's version table, quickstart,
|
|||||||
| [`reflection`](reflection/) | Java Reflection and MethodHandles in 2026: How They Work and What They Cost |
|
| [`reflection`](reflection/) | Java Reflection and MethodHandles in 2026: How They Work and What They Cost |
|
||||||
| [`jmh-basics`](jmh-basics/) | JMH Tutorial: Writing Java Microbenchmarks That Don't Lie |
|
| [`jmh-basics`](jmh-basics/) | JMH Tutorial: Writing Java Microbenchmarks That Don't Lie |
|
||||||
| [`string-format`](string-format/) | String.format, formatted() and printf in Java: The Complete Cheat Sheet |
|
| [`string-format`](string-format/) | String.format, formatted() and printf in Java: The Complete Cheat Sheet |
|
||||||
|
| [`equals-hashcode`](equals-hashcode/) | equals() and hashCode() in Java: The Contract, Real Bugs and How Records Change It |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# equals-hashcode
|
||||||
|
|
||||||
|
Companion code for the ankurm.com post *"equals() and hashCode() in Java: The Contract, Real Bugs and How Records Change It."*
|
||||||
|
Module in `java-core-examples`, the Java-core series. There is no `docs/` folder: the deeper material lives in the post itself.
|
||||||
|
|
||||||
|
## Versions this was built and tested against
|
||||||
|
|
||||||
|
| Component | Version | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| JDK | 25.0.5+8 (Temurin, LTS) | Records (final since 16) generate `equals`/`hashCode`. |
|
||||||
|
| JUnit Jupiter | 5.11.0 | |
|
||||||
|
| Maven | 3.9.11 | |
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=/path/to/jdk-25
|
||||||
|
mvn compile
|
||||||
|
scripts/run.sh MutableKeyDemo # one demo
|
||||||
|
scripts/run-all.sh # regenerate every file in output/
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's in here
|
||||||
|
|
||||||
|
| File | What it shows |
|
||||||
|
|---|---|
|
||||||
|
| `ContractDemo.java` | The five rules, each broken on purpose: symmetry (`instanceof` + extra field), transitivity (the "lenient" fix), consistency. |
|
||||||
|
| `MissingHashCodeDemo.java` | `equals()` without `hashCode()`; a constant `hashCode()` and the 2,002,999 `equals()` calls it costs for 2,000 lookups. |
|
||||||
|
| `MutableKeyDemo.java` | A key mutated after insertion: `get` returns null, `remove` fails, iteration still sees it. |
|
||||||
|
| `SymmetryDemo.java` | `java.util.Date` vs `java.sql.Timestamp`: the JDK breaks symmetry itself. |
|
||||||
|
| `RecordsDemo.java` | What records generate; array components, mutable lists, `NaN` and `-0.0`; overriding `equals`. |
|
||||||
|
| `EntityIdDemo.java` | The JPA entity trap simulated without a database (id null until "saved"). No Hibernate involved. |
|
||||||
|
| `HashCodeRecipeDemo.java` | What `String`, `List`, `Set`, `Map`, `Objects.hash`, `Arrays.hashCode` actually compute. |
|
||||||
|
| `EqualsHashCodeTest.java` | Pins every claim above as an assertion. |
|
||||||
|
| `output/01-08` | Captured runs of everything above. |
|
||||||
|
|
||||||
|
## License
|
||||||
|
MIT - see [LICENSE](../LICENSE).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
== Reflexive / null ==
|
||||||
|
p.equals(p) = true
|
||||||
|
p.equals(null) = false
|
||||||
|
|
||||||
|
== Symmetry broken by instanceof + extra field ==
|
||||||
|
plain.equals(red) = true
|
||||||
|
red.equals(plain) = false
|
||||||
|
List.of(plain).contains(red) = false
|
||||||
|
List.of(red).contains(plain) = true
|
||||||
|
|
||||||
|
== Transitivity broken by the lenient fix ==
|
||||||
|
r.equals(plain) = true
|
||||||
|
plain.equals(b) = true
|
||||||
|
r.equals(b) = false
|
||||||
|
|
||||||
|
== Consistency broken by a changing equals() ==
|
||||||
|
f.equals(f2) x4 = true false true false
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
== equals() without hashCode() ==
|
||||||
|
a.equals(b) = true
|
||||||
|
a.hashCode() == b.hashCode() = false
|
||||||
|
HashSet size after adding a then b = 2
|
||||||
|
HashSet.contains(new equal key) = false
|
||||||
|
HashMap.get(equal key) = null
|
||||||
|
|
||||||
|
== with hashCode() ==
|
||||||
|
HashSet size after adding c then d = 1
|
||||||
|
HashSet.contains(new equal key) = true
|
||||||
|
|
||||||
|
== a legal but useless hashCode(): return 42 ==
|
||||||
|
keys = 2000
|
||||||
|
equals() calls for 2000 gets, constant hash = 2002999
|
||||||
|
equals() calls for 2000 gets, good hash = 2000
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
before: get = 100, contains = true
|
||||||
|
|
||||||
|
after acc.owner = "ankur":
|
||||||
|
map.get(acc) = null
|
||||||
|
set.contains(acc) = false
|
||||||
|
map.containsKey(acc) = false
|
||||||
|
map.size() = 1
|
||||||
|
iteration still sees it = [Account(ankur)]
|
||||||
|
map.remove(acc) = null
|
||||||
|
map.size() after remove = 1
|
||||||
|
|
||||||
|
set.add(acc) again -> size = 2 [Account(ankur), Account(ankur)] (one object, two entries)
|
||||||
|
|
||||||
|
immutable key: map.get("asha") = 1
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
date.equals(ts) = true
|
||||||
|
ts.equals(date) = false
|
||||||
|
date.getTime() == ts.getTime() = true
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
== generated equals/hashCode ==
|
||||||
|
a.equals(b) = true
|
||||||
|
a.hashCode() == b.hashCode() = true
|
||||||
|
new HashSet of both, size = 1
|
||||||
|
a == b = false
|
||||||
|
|
||||||
|
== array component: reference equality ==
|
||||||
|
w1.equals(w2) = false
|
||||||
|
same array shared: true
|
||||||
|
|
||||||
|
== mutable List component: equals works, hashCode can change ==
|
||||||
|
hashCode before = 4714, after list.add(3) = 34537
|
||||||
|
record in HashSet, list mutated -> contains = false
|
||||||
|
|
||||||
|
== doubles compare with Double.compare semantics ==
|
||||||
|
NaN record equals NaN record = true
|
||||||
|
Double.NaN == Double.NaN = false
|
||||||
|
0.0 record equals -0.0 record = false
|
||||||
|
0.0 == -0.0 = true
|
||||||
|
|
||||||
|
== overriding equals in a record ==
|
||||||
|
t1.equals(t2) = true
|
||||||
|
toString still shows both fields: Tagged[isbn=1, title=Effective Java 3e]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
== id-based equals/hashCode, id assigned on save ==
|
||||||
|
two unsaved entities equal? true (both ids null)
|
||||||
|
set size after adding two different unsaved entities = 1
|
||||||
|
add, then save(): set.contains(c) = false
|
||||||
|
|
||||||
|
== constant hashCode ==
|
||||||
|
add, then save(): set.contains(d) = true
|
||||||
|
|
||||||
|
== natural key, immutable ==
|
||||||
|
add, then save(): set.contains(e) = true
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
== what the JDK uses ==
|
||||||
|
"hello".hashCode() = 99162322
|
||||||
|
31*h + c over "hello" = 99162322
|
||||||
|
List.of(1,2,3).hashCode() = 30817
|
||||||
|
Arrays.hashCode({1,2,3}) = 30817
|
||||||
|
Objects.hash(1,2,3) = 30817
|
||||||
|
Set.of(1,2,3).hashCode() = 6 (sum of elements)
|
||||||
|
Map.of(1,2).hashCode() = 3 (key^value per entry, summed)
|
||||||
|
|
||||||
|
== Objects.hash(...) traps ==
|
||||||
|
Objects.hash("a") vs "a".hashCode() = 128 vs 97
|
||||||
|
Objects.hash(1, 2) == Objects.hash(2, 1) ? false
|
||||||
|
Objects.hash() (no args) = 1
|
||||||
|
Objects.hash((Object) null) = 31
|
||||||
|
|
||||||
|
== Arrays as fields ==
|
||||||
|
x.equals(y) = false, Arrays.equals = true
|
||||||
|
x.hashCode() == y.hashCode() ? false
|
||||||
|
Arrays.hashCode equal ? true
|
||||||
|
|
||||||
|
== equal hashCodes do not mean equal objects ==
|
||||||
|
"Aa".hashCode() = 2112, "BB".hashCode() = 2112, equal strings? false
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.205 s -- in com.ankurm.equalshashcode.EqualsHashCodeTest
|
||||||
|
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>java-core-examples</artifactId>
|
||||||
|
<version>1.0</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>equals-hashcode</artifactId>
|
||||||
|
<name>equals-hashcode</name>
|
||||||
|
<description>equals() and hashCode() in Java: the contract, the bugs it produces in HashMap and HashSet, mutable keys, records, and the JPA entity trap.</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.11.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
<configuration>
|
||||||
|
<release>25</release>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
<configuration>
|
||||||
|
<argLine>-Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerates every file in output/. Hash-collision counts are deterministic; nothing here is timing-based.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
mvn -q -B compile
|
||||||
|
run() { java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.equalshashcode.$1" 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' > "output/$2.txt"; }
|
||||||
|
run ContractDemo 01-contract
|
||||||
|
run MissingHashCodeDemo 02-missing-hashcode
|
||||||
|
run MutableKeyDemo 03-mutable-key
|
||||||
|
run SymmetryDemo 04-jdk-symmetry
|
||||||
|
run RecordsDemo 05-records
|
||||||
|
run EntityIdDemo 06-entity-id
|
||||||
|
run HashCodeRecipeDemo 07-hashcode-recipes
|
||||||
|
mvn -B test 2>&1 | grep -E 'Tests run|BUILD|ERROR|FAIL' | grep -v 'Picked up' > output/08-tests.txt
|
||||||
|
echo "regenerated:"; ls output
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage: scripts/run.sh MutableKeyDemo
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
mvn -q -B compile && java -cp target/classes "com.ankurm.equalshashcode.$1"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.ankurm.equalshashcode;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** The five equals() rules, each broken on purpose. Output: output/01-contract.txt */
|
||||||
|
public class ContractDemo {
|
||||||
|
|
||||||
|
/** A class whose equals() is fine. */
|
||||||
|
static class Point {
|
||||||
|
final int x, y;
|
||||||
|
Point(int x, int y) { this.x = x; this.y = y; }
|
||||||
|
@Override public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof Point p)) return false;
|
||||||
|
return x == p.x && y == p.y;
|
||||||
|
}
|
||||||
|
@Override public int hashCode() { return Objects.hash(x, y); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subclass that adds a field and uses instanceof: breaks symmetry. */
|
||||||
|
static class ColorPoint extends Point {
|
||||||
|
final String color;
|
||||||
|
ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
|
||||||
|
@Override public boolean equals(Object o) {
|
||||||
|
if (!(o instanceof ColorPoint c)) return false; // a plain Point is never equal
|
||||||
|
return super.equals(o) && color.equals(c.color);
|
||||||
|
}
|
||||||
|
@Override public int hashCode() { return Objects.hash(super.hashCode(), color); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fix attempt: ignore colour when comparing with a plain Point. Breaks transitivity. */
|
||||||
|
static final class LenientColorPoint extends Point {
|
||||||
|
final String color;
|
||||||
|
LenientColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
|
||||||
|
@Override public boolean equals(Object o) {
|
||||||
|
if (!(o instanceof Point)) return false;
|
||||||
|
if (!(o instanceof LenientColorPoint c)) return o.equals(this);
|
||||||
|
return super.equals(o) && color.equals(c.color);
|
||||||
|
}
|
||||||
|
@Override public int hashCode() { return super.hashCode(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistency: equals() that depends on something that changes between calls. */
|
||||||
|
static final class Flaky {
|
||||||
|
static int calls;
|
||||||
|
@Override public boolean equals(Object o) { return o instanceof Flaky && (calls++ % 2 == 0); }
|
||||||
|
@Override public int hashCode() { return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
Point p = new Point(1, 2);
|
||||||
|
System.out.println("== Reflexive / null ==");
|
||||||
|
System.out.println("p.equals(p) = " + p.equals(p));
|
||||||
|
System.out.println("p.equals(null) = " + p.equals(null));
|
||||||
|
|
||||||
|
System.out.println("\n== Symmetry broken by instanceof + extra field ==");
|
||||||
|
Point plain = new Point(1, 2);
|
||||||
|
ColorPoint red = new ColorPoint(1, 2, "red");
|
||||||
|
System.out.println("plain.equals(red) = " + plain.equals(red));
|
||||||
|
System.out.println("red.equals(plain) = " + red.equals(plain));
|
||||||
|
List<Point> list = new ArrayList<>(List.of(plain));
|
||||||
|
System.out.println("List.of(plain).contains(red) = " + list.contains(red));
|
||||||
|
List<Point> list2 = new ArrayList<>(List.of(red));
|
||||||
|
System.out.println("List.of(red).contains(plain) = " + list2.contains(plain));
|
||||||
|
|
||||||
|
System.out.println("\n== Transitivity broken by the lenient fix ==");
|
||||||
|
LenientColorPoint r = new LenientColorPoint(1, 2, "red");
|
||||||
|
LenientColorPoint b = new LenientColorPoint(1, 2, "blue");
|
||||||
|
System.out.println("r.equals(plain) = " + r.equals(plain));
|
||||||
|
System.out.println("plain.equals(b) = " + plain.equals(b));
|
||||||
|
System.out.println("r.equals(b) = " + r.equals(b));
|
||||||
|
|
||||||
|
System.out.println("\n== Consistency broken by a changing equals() ==");
|
||||||
|
Flaky f = new Flaky();
|
||||||
|
System.out.println("f.equals(f2) x4 = " + f.equals(new Flaky()) + " " + f.equals(new Flaky())
|
||||||
|
+ " " + f.equals(new Flaky()) + " " + f.equals(new Flaky()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.ankurm.equalshashcode;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JPA entity trap, simulated without a database: an id that is null until "save" assigns it.
|
||||||
|
* No Hibernate on the classpath, so this shows the Java mechanism only. Output: output/06-entity-id.txt
|
||||||
|
*/
|
||||||
|
public class EntityIdDemo {
|
||||||
|
|
||||||
|
static long sequence = 100;
|
||||||
|
|
||||||
|
/** equals/hashCode on the database id: what many IDE templates generate. */
|
||||||
|
static final class IdEntity {
|
||||||
|
Long id; final String name;
|
||||||
|
IdEntity(String name) { this.name = name; }
|
||||||
|
void save() { if (id == null) id = ++sequence; } // stands in for persist()
|
||||||
|
@Override public boolean equals(Object o) { return o instanceof IdEntity e && Objects.equals(id, e.id); }
|
||||||
|
@Override public int hashCode() { return Objects.hashCode(id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Common workaround: constant hashCode, id-based equals when both ids are set. */
|
||||||
|
static final class ConstantHashEntity {
|
||||||
|
Long id; final String name;
|
||||||
|
ConstantHashEntity(String name) { this.name = name; }
|
||||||
|
void save() { if (id == null) id = ++sequence; }
|
||||||
|
@Override public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
return o instanceof ConstantHashEntity e && id != null && id.equals(e.id);
|
||||||
|
}
|
||||||
|
@Override public int hashCode() { return ConstantHashEntity.class.hashCode(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Business key: set once at construction and never changed. */
|
||||||
|
static final class NaturalKeyEntity {
|
||||||
|
Long id; final String email;
|
||||||
|
NaturalKeyEntity(String email) { this.email = email; }
|
||||||
|
void save() { if (id == null) id = ++sequence; }
|
||||||
|
@Override public boolean equals(Object o) { return o instanceof NaturalKeyEntity e && email.equals(e.email); }
|
||||||
|
@Override public int hashCode() { return email.hashCode(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("== id-based equals/hashCode, id assigned on save ==");
|
||||||
|
IdEntity a = new IdEntity("a"), b = new IdEntity("b");
|
||||||
|
System.out.println("two unsaved entities equal? " + a.equals(b) + " (both ids null)");
|
||||||
|
Set<IdEntity> set = new HashSet<>();
|
||||||
|
set.add(a);
|
||||||
|
set.add(b);
|
||||||
|
System.out.println("set size after adding two different unsaved entities = " + set.size());
|
||||||
|
Set<IdEntity> set2 = new HashSet<>();
|
||||||
|
IdEntity c = new IdEntity("c");
|
||||||
|
set2.add(c);
|
||||||
|
c.save();
|
||||||
|
System.out.println("add, then save(): set.contains(c) = " + set2.contains(c));
|
||||||
|
|
||||||
|
System.out.println("\n== constant hashCode ==");
|
||||||
|
ConstantHashEntity d = new ConstantHashEntity("d");
|
||||||
|
Set<ConstantHashEntity> set3 = new HashSet<>();
|
||||||
|
set3.add(d);
|
||||||
|
d.save();
|
||||||
|
System.out.println("add, then save(): set.contains(d) = " + set3.contains(d));
|
||||||
|
|
||||||
|
System.out.println("\n== natural key, immutable ==");
|
||||||
|
NaturalKeyEntity e = new NaturalKeyEntity("[email protected]");
|
||||||
|
Set<NaturalKeyEntity> set4 = new HashSet<>();
|
||||||
|
set4.add(e);
|
||||||
|
e.save();
|
||||||
|
System.out.println("add, then save(): set.contains(e) = " + set4.contains(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.ankurm.equalshashcode;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** hashCode() recipes and what the JDK hands you. Output: output/07-hashcode-recipes.txt */
|
||||||
|
public class HashCodeRecipeDemo {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("== what the JDK uses ==");
|
||||||
|
System.out.println("\"hello\".hashCode() = " + "hello".hashCode());
|
||||||
|
int h = 0; for (char ch : "hello".toCharArray()) h = 31 * h + ch;
|
||||||
|
System.out.println("31*h + c over \"hello\" = " + h);
|
||||||
|
System.out.println("List.of(1,2,3).hashCode() = " + List.of(1, 2, 3).hashCode());
|
||||||
|
System.out.println("Arrays.hashCode({1,2,3}) = " + Arrays.hashCode(new int[]{1, 2, 3}));
|
||||||
|
System.out.println("Objects.hash(1,2,3) = " + Objects.hash(1, 2, 3));
|
||||||
|
System.out.println("Set.of(1,2,3).hashCode() = " + Set.of(1, 2, 3).hashCode() + " (sum of elements)");
|
||||||
|
System.out.println("Map.of(1,2).hashCode() = " + Map.of(1, 2).hashCode() + " (key^value per entry, summed)");
|
||||||
|
|
||||||
|
System.out.println("\n== Objects.hash(...) traps ==");
|
||||||
|
System.out.println("Objects.hash(\"a\") vs \"a\".hashCode() = " + Objects.hash("a") + " vs " + "a".hashCode());
|
||||||
|
System.out.println("Objects.hash(1, 2) == Objects.hash(2, 1) ? " + (Objects.hash(1, 2) == Objects.hash(2, 1)));
|
||||||
|
System.out.println("Objects.hash() (no args) = " + Objects.hash());
|
||||||
|
System.out.println("Objects.hash((Object) null) = " + Objects.hash((Object) null));
|
||||||
|
|
||||||
|
System.out.println("\n== Arrays as fields ==");
|
||||||
|
int[] x = {1, 2}, y = {1, 2};
|
||||||
|
System.out.println("x.equals(y) = " + x.equals(y) + ", Arrays.equals = " + Arrays.equals(x, y));
|
||||||
|
System.out.println("x.hashCode() == y.hashCode() ? " + (x.hashCode() == y.hashCode()));
|
||||||
|
System.out.println("Arrays.hashCode equal ? " + (Arrays.hashCode(x) == Arrays.hashCode(y)));
|
||||||
|
|
||||||
|
System.out.println("\n== equal hashCodes do not mean equal objects ==");
|
||||||
|
System.out.println("\"Aa\".hashCode() = " + "Aa".hashCode() + ", \"BB\".hashCode() = " + "BB".hashCode()
|
||||||
|
+ ", equal strings? " + "Aa".equals("BB"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.ankurm.equalshashcode;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** equals() without hashCode(): the HashSet/HashMap bug. Output: output/02-missing-hashcode.txt */
|
||||||
|
public class MissingHashCodeDemo {
|
||||||
|
|
||||||
|
static final class Broken {
|
||||||
|
final String isbn;
|
||||||
|
Broken(String isbn) { this.isbn = isbn; }
|
||||||
|
@Override public boolean equals(Object o) { return o instanceof Broken b && isbn.equals(b.isbn); }
|
||||||
|
// hashCode() NOT overridden: Object.hashCode() is identity-based
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Fixed {
|
||||||
|
final String isbn;
|
||||||
|
Fixed(String isbn) { this.isbn = isbn; }
|
||||||
|
@Override public boolean equals(Object o) { return o instanceof Fixed b && isbn.equals(b.isbn); }
|
||||||
|
@Override public int hashCode() { return isbn.hashCode(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hashCode() that is legal but useless: every key in one bucket. */
|
||||||
|
static final class Constant {
|
||||||
|
static long equalsCalls;
|
||||||
|
final int id;
|
||||||
|
Constant(int id) { this.id = id; }
|
||||||
|
@Override public boolean equals(Object o) { equalsCalls++; return o instanceof Constant c && id == c.id; }
|
||||||
|
@Override public int hashCode() { return 42; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Good {
|
||||||
|
static long equalsCalls;
|
||||||
|
final int id;
|
||||||
|
Good(int id) { this.id = id; }
|
||||||
|
@Override public boolean equals(Object o) { equalsCalls++; return o instanceof Good c && id == c.id; }
|
||||||
|
@Override public int hashCode() { return Integer.hashCode(id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
Broken a = new Broken("978-0134685991"), b = new Broken("978-0134685991");
|
||||||
|
System.out.println("== equals() without hashCode() ==");
|
||||||
|
System.out.println("a.equals(b) = " + a.equals(b));
|
||||||
|
System.out.println("a.hashCode() == b.hashCode() = " + (a.hashCode() == b.hashCode()));
|
||||||
|
Set<Broken> bad = new HashSet<>(List.of(a));
|
||||||
|
bad.add(b);
|
||||||
|
System.out.println("HashSet size after adding a then b = " + bad.size());
|
||||||
|
System.out.println("HashSet.contains(new equal key) = " + bad.contains(new Broken("978-0134685991")));
|
||||||
|
Map<Broken, String> badMap = new HashMap<>();
|
||||||
|
badMap.put(a, "Effective Java");
|
||||||
|
System.out.println("HashMap.get(equal key) = " + badMap.get(b));
|
||||||
|
|
||||||
|
Fixed c = new Fixed("978-0134685991"), d = new Fixed("978-0134685991");
|
||||||
|
Set<Fixed> ok = new HashSet<>(List.of(c));
|
||||||
|
ok.add(d);
|
||||||
|
System.out.println("\n== with hashCode() ==");
|
||||||
|
System.out.println("HashSet size after adding c then d = " + ok.size());
|
||||||
|
System.out.println("HashSet.contains(new equal key) = " + ok.contains(new Fixed("978-0134685991")));
|
||||||
|
|
||||||
|
System.out.println("\n== a legal but useless hashCode(): return 42 ==");
|
||||||
|
int n = 2000;
|
||||||
|
Map<Constant, Integer> m1 = new HashMap<>();
|
||||||
|
for (int i = 0; i < n; i++) m1.put(new Constant(i), i);
|
||||||
|
Constant.equalsCalls = 0;
|
||||||
|
for (int i = 0; i < n; i++) m1.get(new Constant(i));
|
||||||
|
Map<Good, Integer> m2 = new HashMap<>();
|
||||||
|
for (int i = 0; i < n; i++) m2.put(new Good(i), i);
|
||||||
|
Good.equalsCalls = 0;
|
||||||
|
for (int i = 0; i < n; i++) m2.get(new Good(i));
|
||||||
|
System.out.println("keys = " + n);
|
||||||
|
System.out.println("equals() calls for " + n + " gets, constant hash = " + Constant.equalsCalls);
|
||||||
|
System.out.println("equals() calls for " + n + " gets, good hash = " + Good.equalsCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.ankurm.equalshashcode;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Mutating a key after it went into a hash collection. Output: output/03-mutable-key.txt */
|
||||||
|
public class MutableKeyDemo {
|
||||||
|
|
||||||
|
static final class Account {
|
||||||
|
String owner;
|
||||||
|
Account(String owner) { this.owner = owner; }
|
||||||
|
@Override public boolean equals(Object o) { return o instanceof Account a && owner.equals(a.owner); }
|
||||||
|
@Override public int hashCode() { return owner.hashCode(); }
|
||||||
|
@Override public String toString() { return "Account(" + owner + ")"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
Account acc = new Account("asha");
|
||||||
|
Map<Account, Integer> balances = new HashMap<>();
|
||||||
|
Set<Account> set = new HashSet<>();
|
||||||
|
balances.put(acc, 100);
|
||||||
|
set.add(acc);
|
||||||
|
System.out.println("before: get = " + balances.get(acc) + ", contains = " + set.contains(acc));
|
||||||
|
|
||||||
|
acc.owner = "ankur"; // mutate the key while it is inside the collections
|
||||||
|
|
||||||
|
System.out.println("\nafter acc.owner = \"ankur\":");
|
||||||
|
System.out.println("map.get(acc) = " + balances.get(acc));
|
||||||
|
System.out.println("set.contains(acc) = " + set.contains(acc));
|
||||||
|
System.out.println("map.containsKey(acc) = " + balances.containsKey(acc));
|
||||||
|
System.out.println("map.size() = " + balances.size());
|
||||||
|
System.out.println("iteration still sees it = " + balances.keySet());
|
||||||
|
System.out.println("map.remove(acc) = " + balances.remove(acc));
|
||||||
|
System.out.println("map.size() after remove = " + balances.size());
|
||||||
|
|
||||||
|
// adding it again creates a second entry for the very same object
|
||||||
|
set.add(acc);
|
||||||
|
System.out.println("\nset.add(acc) again -> size = " + set.size() + " " + set + " (one object, two entries)");
|
||||||
|
|
||||||
|
// Strings are safe because they are immutable
|
||||||
|
Map<String, Integer> safe = new HashMap<>();
|
||||||
|
String k = "asha";
|
||||||
|
safe.put(k, 1);
|
||||||
|
k = k + "!"; // new String; the key inside the map is untouched
|
||||||
|
System.out.println("\nimmutable key: map.get(\"asha\") = " + safe.get("asha"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.ankurm.equalshashcode;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** What a record generates and where it surprises. Output: output/05-records.txt */
|
||||||
|
public class RecordsDemo {
|
||||||
|
|
||||||
|
record Book(String isbn, String title) {}
|
||||||
|
record WithArray(String name, int[] values) {}
|
||||||
|
record WithList(String name, List<Integer> values) {}
|
||||||
|
record WithDouble(double d) {}
|
||||||
|
record Tagged(String isbn, String title) { // custom equals: identity is the ISBN only
|
||||||
|
@Override public boolean equals(Object o) { return o instanceof Tagged t && isbn.equals(t.isbn); }
|
||||||
|
@Override public int hashCode() { return isbn.hashCode(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("== generated equals/hashCode ==");
|
||||||
|
Book a = new Book("1", "Effective Java"), b = new Book("1", "Effective Java");
|
||||||
|
System.out.println("a.equals(b) = " + a.equals(b));
|
||||||
|
System.out.println("a.hashCode() == b.hashCode() = " + (a.hashCode() == b.hashCode()));
|
||||||
|
System.out.println("new HashSet of both, size = " + new HashSet<>(List.of(a)).size());
|
||||||
|
System.out.println("a == b = " + (a == b));
|
||||||
|
|
||||||
|
System.out.println("\n== array component: reference equality ==");
|
||||||
|
WithArray w1 = new WithArray("x", new int[]{1, 2}), w2 = new WithArray("x", new int[]{1, 2});
|
||||||
|
System.out.println("w1.equals(w2) = " + w1.equals(w2));
|
||||||
|
System.out.println("same array shared: " + new WithArray("x", w1.values()).equals(w1));
|
||||||
|
|
||||||
|
System.out.println("\n== mutable List component: equals works, hashCode can change ==");
|
||||||
|
List<Integer> list = new ArrayList<>(List.of(1, 2));
|
||||||
|
WithList wl = new WithList("x", list);
|
||||||
|
int before = wl.hashCode();
|
||||||
|
list.add(3);
|
||||||
|
System.out.println("hashCode before = " + before + ", after list.add(3) = " + wl.hashCode());
|
||||||
|
Set<WithList> set = new HashSet<>();
|
||||||
|
List<Integer> l2 = new ArrayList<>(List.of(1, 2));
|
||||||
|
WithList key = new WithList("y", l2);
|
||||||
|
set.add(key);
|
||||||
|
l2.add(3);
|
||||||
|
System.out.println("record in HashSet, list mutated -> contains = " + set.contains(key));
|
||||||
|
|
||||||
|
System.out.println("\n== doubles compare with Double.compare semantics ==");
|
||||||
|
System.out.println("NaN record equals NaN record = " + new WithDouble(Double.NaN).equals(new WithDouble(Double.NaN)));
|
||||||
|
System.out.println("Double.NaN == Double.NaN = " + (Double.NaN == Double.NaN));
|
||||||
|
System.out.println("0.0 record equals -0.0 record = " + new WithDouble(0.0).equals(new WithDouble(-0.0)));
|
||||||
|
System.out.println("0.0 == -0.0 = " + (0.0 == -0.0));
|
||||||
|
|
||||||
|
System.out.println("\n== overriding equals in a record ==");
|
||||||
|
Tagged t1 = new Tagged("1", "Effective Java"), t2 = new Tagged("1", "Effective Java 3e");
|
||||||
|
System.out.println("t1.equals(t2) = " + t1.equals(t2));
|
||||||
|
System.out.println("toString still shows both fields: " + t2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.ankurm.equalshashcode;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/** The JDK itself breaks symmetry once. Output: output/04-jdk-symmetry.txt */
|
||||||
|
public class SymmetryDemo {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
long ms = 1_700_000_000_123L;
|
||||||
|
Date date = new Date(ms);
|
||||||
|
Timestamp ts = new Timestamp(ms);
|
||||||
|
System.out.println("date.equals(ts) = " + date.equals(ts));
|
||||||
|
System.out.println("ts.equals(date) = " + ts.equals(date));
|
||||||
|
System.out.println("date.getTime() == ts.getTime() = " + (date.getTime() == ts.getTime()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.ankurm.equalshashcode;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.*;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/** Pins every claim the post makes as an assertion. */
|
||||||
|
class EqualsHashCodeTest {
|
||||||
|
|
||||||
|
@Test void instanceofPlusExtraFieldBreaksSymmetry() {
|
||||||
|
var plain = new ContractDemo.Point(1, 2);
|
||||||
|
var red = new ContractDemo.ColorPoint(1, 2, "red");
|
||||||
|
assertTrue(plain.equals(red));
|
||||||
|
assertFalse(red.equals(plain));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void lenientFixBreaksTransitivity() {
|
||||||
|
var plain = new ContractDemo.Point(1, 2);
|
||||||
|
var r = new ContractDemo.LenientColorPoint(1, 2, "red");
|
||||||
|
var b = new ContractDemo.LenientColorPoint(1, 2, "blue");
|
||||||
|
assertTrue(r.equals(plain));
|
||||||
|
assertTrue(plain.equals(b));
|
||||||
|
assertFalse(r.equals(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void equalsWithoutHashCodeBreaksHashSet() {
|
||||||
|
var a = new MissingHashCodeDemo.Broken("x");
|
||||||
|
var b = new MissingHashCodeDemo.Broken("x");
|
||||||
|
assertTrue(a.equals(b));
|
||||||
|
Set<MissingHashCodeDemo.Broken> set = new HashSet<>(List.of(a));
|
||||||
|
set.add(b);
|
||||||
|
assertEquals(2, set.size());
|
||||||
|
assertFalse(set.contains(new MissingHashCodeDemo.Broken("x")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void withHashCodeHashSetDedupes() {
|
||||||
|
Set<MissingHashCodeDemo.Fixed> set = new HashSet<>();
|
||||||
|
set.add(new MissingHashCodeDemo.Fixed("x"));
|
||||||
|
set.add(new MissingHashCodeDemo.Fixed("x"));
|
||||||
|
assertEquals(1, set.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void constantHashCodeTurnsLookupsQuadratic() {
|
||||||
|
int n = 2000;
|
||||||
|
Map<MissingHashCodeDemo.Constant, Integer> m = new HashMap<>();
|
||||||
|
for (int i = 0; i < n; i++) m.put(new MissingHashCodeDemo.Constant(i), i);
|
||||||
|
MissingHashCodeDemo.Constant.equalsCalls = 0;
|
||||||
|
for (int i = 0; i < n; i++) m.get(new MissingHashCodeDemo.Constant(i));
|
||||||
|
assertEquals(2_002_999L, MissingHashCodeDemo.Constant.equalsCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void mutatingAKeyLosesTheEntry() {
|
||||||
|
var acc = new MutableKeyDemo.Account("asha");
|
||||||
|
Map<MutableKeyDemo.Account, Integer> m = new HashMap<>();
|
||||||
|
m.put(acc, 100);
|
||||||
|
acc.owner = "ankur";
|
||||||
|
assertNull(m.get(acc));
|
||||||
|
assertEquals(1, m.size());
|
||||||
|
assertNull(m.remove(acc));
|
||||||
|
assertEquals(1, m.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void timestampIsNotSymmetricWithDate() {
|
||||||
|
long ms = 1_700_000_000_123L;
|
||||||
|
assertTrue(new Date(ms).equals(new Timestamp(ms)));
|
||||||
|
assertFalse(new Timestamp(ms).equals(new Date(ms)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void recordsGenerateValueEquality() {
|
||||||
|
record Book(String isbn, String title) {}
|
||||||
|
assertEquals(new Book("1", "t"), new Book("1", "t"));
|
||||||
|
assertEquals(new Book("1", "t").hashCode(), new Book("1", "t").hashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void recordArrayComponentUsesReferenceEquality() {
|
||||||
|
record W(int[] v) {}
|
||||||
|
assertNotEquals(new W(new int[]{1}), new W(new int[]{1}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void recordDoubleUsesCompareSemantics() {
|
||||||
|
record D(double d) {}
|
||||||
|
assertEquals(new D(Double.NaN), new D(Double.NaN));
|
||||||
|
assertNotEquals(new D(0.0), new D(-0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void idBasedHashCodeChangesOnSave() {
|
||||||
|
var c = new EntityIdDemo.IdEntity("c");
|
||||||
|
Set<EntityIdDemo.IdEntity> s = new HashSet<>();
|
||||||
|
s.add(c);
|
||||||
|
c.save();
|
||||||
|
assertFalse(s.contains(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void naturalKeySurvivesSave() {
|
||||||
|
var e = new EntityIdDemo.NaturalKeyEntity("[email protected]");
|
||||||
|
Set<EntityIdDemo.NaturalKeyEntity> s = new HashSet<>();
|
||||||
|
s.add(e);
|
||||||
|
e.save();
|
||||||
|
assertTrue(s.contains(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void objectsHashOfListMatchesListHashCode() {
|
||||||
|
assertEquals(List.of(1, 2, 3).hashCode(), Objects.hash(1, 2, 3));
|
||||||
|
assertEquals("Aa".hashCode(), "BB".hashCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user