From 682b7b067ce8bc708a891df2e49eeca0d86120e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 20:08:35 +0530 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017ic4b66uwsWr9B1ouyrzUh --- README.md | 1 + equals-hashcode/README.md | 38 ++++++ equals-hashcode/output/01-contract.txt | 17 +++ .../output/02-missing-hashcode.txt | 15 +++ equals-hashcode/output/03-mutable-key.txt | 14 +++ equals-hashcode/output/04-jdk-symmetry.txt | 3 + equals-hashcode/output/05-records.txt | 23 ++++ equals-hashcode/output/06-entity-id.txt | 10 ++ .../output/07-hashcode-recipes.txt | 22 ++++ equals-hashcode/output/08-tests.txt | 3 + equals-hashcode/pom.xml | 46 ++++++++ equals-hashcode/scripts/run-all.sh | 15 +++ equals-hashcode/scripts/run.sh | 4 + .../ankurm/equalshashcode/ContractDemo.java | 78 +++++++++++++ .../ankurm/equalshashcode/EntityIdDemo.java | 71 ++++++++++++ .../equalshashcode/HashCodeRecipeDemo.java | 34 ++++++ .../equalshashcode/MissingHashCodeDemo.java | 73 ++++++++++++ .../ankurm/equalshashcode/MutableKeyDemo.java | 46 ++++++++ .../ankurm/equalshashcode/RecordsDemo.java | 54 +++++++++ .../ankurm/equalshashcode/SymmetryDemo.java | 16 +++ .../equalshashcode/EqualsHashCodeTest.java | 108 ++++++++++++++++++ pom.xml | 1 + 22 files changed, 692 insertions(+) create mode 100644 equals-hashcode/README.md create mode 100644 equals-hashcode/output/01-contract.txt create mode 100644 equals-hashcode/output/02-missing-hashcode.txt create mode 100644 equals-hashcode/output/03-mutable-key.txt create mode 100644 equals-hashcode/output/04-jdk-symmetry.txt create mode 100644 equals-hashcode/output/05-records.txt create mode 100644 equals-hashcode/output/06-entity-id.txt create mode 100644 equals-hashcode/output/07-hashcode-recipes.txt create mode 100644 equals-hashcode/output/08-tests.txt create mode 100644 equals-hashcode/pom.xml create mode 100755 equals-hashcode/scripts/run-all.sh create mode 100755 equals-hashcode/scripts/run.sh create mode 100644 equals-hashcode/src/main/java/com/ankurm/equalshashcode/ContractDemo.java create mode 100644 equals-hashcode/src/main/java/com/ankurm/equalshashcode/EntityIdDemo.java create mode 100644 equals-hashcode/src/main/java/com/ankurm/equalshashcode/HashCodeRecipeDemo.java create mode 100644 equals-hashcode/src/main/java/com/ankurm/equalshashcode/MissingHashCodeDemo.java create mode 100644 equals-hashcode/src/main/java/com/ankurm/equalshashcode/MutableKeyDemo.java create mode 100644 equals-hashcode/src/main/java/com/ankurm/equalshashcode/RecordsDemo.java create mode 100644 equals-hashcode/src/main/java/com/ankurm/equalshashcode/SymmetryDemo.java create mode 100644 equals-hashcode/src/test/java/com/ankurm/equalshashcode/EqualsHashCodeTest.java diff --git a/README.md b/README.md index a8493d7..a84db9f 100644 --- a/README.md +++ b/README.md @@ -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 | | [`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 | +| [`equals-hashcode`](equals-hashcode/) | equals() and hashCode() in Java: The Contract, Real Bugs and How Records Change It | ## License diff --git a/equals-hashcode/README.md b/equals-hashcode/README.md new file mode 100644 index 0000000..23e5cbf --- /dev/null +++ b/equals-hashcode/README.md @@ -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). diff --git a/equals-hashcode/output/01-contract.txt b/equals-hashcode/output/01-contract.txt new file mode 100644 index 0000000..4ce2673 --- /dev/null +++ b/equals-hashcode/output/01-contract.txt @@ -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 diff --git a/equals-hashcode/output/02-missing-hashcode.txt b/equals-hashcode/output/02-missing-hashcode.txt new file mode 100644 index 0000000..4891a1e --- /dev/null +++ b/equals-hashcode/output/02-missing-hashcode.txt @@ -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 diff --git a/equals-hashcode/output/03-mutable-key.txt b/equals-hashcode/output/03-mutable-key.txt new file mode 100644 index 0000000..3e658bb --- /dev/null +++ b/equals-hashcode/output/03-mutable-key.txt @@ -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 diff --git a/equals-hashcode/output/04-jdk-symmetry.txt b/equals-hashcode/output/04-jdk-symmetry.txt new file mode 100644 index 0000000..b17eb49 --- /dev/null +++ b/equals-hashcode/output/04-jdk-symmetry.txt @@ -0,0 +1,3 @@ +date.equals(ts) = true +ts.equals(date) = false +date.getTime() == ts.getTime() = true diff --git a/equals-hashcode/output/05-records.txt b/equals-hashcode/output/05-records.txt new file mode 100644 index 0000000..24c8d85 --- /dev/null +++ b/equals-hashcode/output/05-records.txt @@ -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] diff --git a/equals-hashcode/output/06-entity-id.txt b/equals-hashcode/output/06-entity-id.txt new file mode 100644 index 0000000..cd06e04 --- /dev/null +++ b/equals-hashcode/output/06-entity-id.txt @@ -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 diff --git a/equals-hashcode/output/07-hashcode-recipes.txt b/equals-hashcode/output/07-hashcode-recipes.txt new file mode 100644 index 0000000..c978f0f --- /dev/null +++ b/equals-hashcode/output/07-hashcode-recipes.txt @@ -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 diff --git a/equals-hashcode/output/08-tests.txt b/equals-hashcode/output/08-tests.txt new file mode 100644 index 0000000..440cdc6 --- /dev/null +++ b/equals-hashcode/output/08-tests.txt @@ -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 diff --git a/equals-hashcode/pom.xml b/equals-hashcode/pom.xml new file mode 100644 index 0000000..98a419c --- /dev/null +++ b/equals-hashcode/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + + com.ankurm + java-core-examples + 1.0 + + + equals-hashcode + equals-hashcode + equals() and hashCode() in Java: the contract, the bugs it produces in HashMap and HashSet, mutable keys, records, and the JPA entity trap. + + + + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 25 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8 + + + + + diff --git a/equals-hashcode/scripts/run-all.sh b/equals-hashcode/scripts/run-all.sh new file mode 100755 index 0000000..5c915aa --- /dev/null +++ b/equals-hashcode/scripts/run-all.sh @@ -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 diff --git a/equals-hashcode/scripts/run.sh b/equals-hashcode/scripts/run.sh new file mode 100755 index 0000000..dc4b307 --- /dev/null +++ b/equals-hashcode/scripts/run.sh @@ -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" diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/ContractDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/ContractDemo.java new file mode 100644 index 0000000..43b7652 --- /dev/null +++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/ContractDemo.java @@ -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 list = new ArrayList<>(List.of(plain)); + System.out.println("List.of(plain).contains(red) = " + list.contains(red)); + List 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())); + } +} diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/EntityIdDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/EntityIdDemo.java new file mode 100644 index 0000000..c3328cb --- /dev/null +++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/EntityIdDemo.java @@ -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 set = new HashSet<>(); + set.add(a); + set.add(b); + System.out.println("set size after adding two different unsaved entities = " + set.size()); + Set 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 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("a@example.com"); + Set set4 = new HashSet<>(); + set4.add(e); + e.save(); + System.out.println("add, then save(): set.contains(e) = " + set4.contains(e)); + } +} diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/HashCodeRecipeDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/HashCodeRecipeDemo.java new file mode 100644 index 0000000..9a98de3 --- /dev/null +++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/HashCodeRecipeDemo.java @@ -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")); + } +} diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MissingHashCodeDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MissingHashCodeDemo.java new file mode 100644 index 0000000..85d6094 --- /dev/null +++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MissingHashCodeDemo.java @@ -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 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 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 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 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 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); + } +} diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MutableKeyDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MutableKeyDemo.java new file mode 100644 index 0000000..a3cd51c --- /dev/null +++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MutableKeyDemo.java @@ -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 balances = new HashMap<>(); + Set 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 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")); + } +} diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/RecordsDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/RecordsDemo.java new file mode 100644 index 0000000..541b474 --- /dev/null +++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/RecordsDemo.java @@ -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 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 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 set = new HashSet<>(); + List 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); + } +} diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/SymmetryDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/SymmetryDemo.java new file mode 100644 index 0000000..a2aedfa --- /dev/null +++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/SymmetryDemo.java @@ -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())); + } +} diff --git a/equals-hashcode/src/test/java/com/ankurm/equalshashcode/EqualsHashCodeTest.java b/equals-hashcode/src/test/java/com/ankurm/equalshashcode/EqualsHashCodeTest.java new file mode 100644 index 0000000..347fa19 --- /dev/null +++ b/equals-hashcode/src/test/java/com/ankurm/equalshashcode/EqualsHashCodeTest.java @@ -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 set = new HashSet<>(List.of(a)); + set.add(b); + assertEquals(2, set.size()); + assertFalse(set.contains(new MissingHashCodeDemo.Broken("x"))); + } + + @Test void withHashCodeHashSetDedupes() { + Set 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 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 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 s = new HashSet<>(); + s.add(c); + c.save(); + assertFalse(s.contains(c)); + } + + @Test void naturalKeySurvivesSave() { + var e = new EntityIdDemo.NaturalKeyEntity("a@example.com"); + Set 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()); + } +} diff --git a/pom.xml b/pom.xml index 11a8cf0..a1a7b17 100644 --- a/pom.xml +++ b/pom.xml @@ -37,6 +37,7 @@ reflection jmh-basics string-format + equals-hashcode