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:
@@ -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