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:
Claude
2026-11-12 20:08:35 +05:30
parent 319697c345
commit 682b7b067c
22 changed files with 692 additions and 0 deletions
+1
View File
@@ -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
+38
View File
@@ -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).
+17
View File
@@ -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
+14
View File
@@ -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
+23
View File
@@ -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]
+10
View File
@@ -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
+3
View File
@@ -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
+46
View File
@@ -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>
+15
View File
@@ -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
+4
View File
@@ -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());
}
}
+1
View File
@@ -37,6 +37,7 @@
<module>reflection</module>
<module>jmh-basics</module>
<module>string-format</module>
<module>equals-hashcode</module>
</modules>
<properties>