equals, hashCode and toString
Override equals and hashCode together so objects behave in sets and map keys, and write toString output that helps in logs without leaking secrets.
@Override public boolean equals(Object o) { ... }
@Override public int hashCode() { return Objects.hash(f1, f2); }
@Override public String toString() { return "Name[f1=" + f1 + "]"; }Every class inherits equals, hashCode and toString from Object. The defaults are based on identity: equals is the same as ==, and toString prints the class name, an @ and a hex hash code. For a value such as an email address or an order line, that is rarely what you want.
The contract
- If
a.equals(b), thena.hashCode() == b.hashCode(). Always override the two together. equalsmust be reflexive, symmetric and transitive, return false fornull, and give the same answer as long as the fields it uses do not change.- Unequal objects may share a hash code; that only costs performance.
HashMap and HashSet use hashCode to pick a bucket and only then call equals. Override only equals and a lookup with an equal object looks in the wrong bucket and misses. That bug is silent: no exception, just duplicates in a set or a cache that never hits.
@Override
public boolean equals(Object o) {
return o instanceof Email other && value.equals(other.value);
}
@Override
public int hashCode() {
return value.hashCode();
}For several fields, Objects.equals(a, b) handles nulls and Objects.hash(f1, f2, f3) combines hash codes. Using instanceof lets subclasses compare equal to the parent; use getClass() != o.getClass() when they must not. Making the class final sidesteps the question.
Mutable fields and map keys
If a field used in hashCode changes after the object is put in a HashSet or used as a HashMap key, the entry is stranded in its old bucket and can no longer be found. Use immutable objects as keys. For database entities, compare by a stable identifier, not by every column, and be careful with ids that are only assigned after saving.
toString for logs
A good toString makes log lines and test failures readable. It is also printed whenever someone logs the object, so never include passwords, tokens or full card numbers; mask or omit them.
Records generate all three methods from their components, which is the easiest way to get them right for data carriers. Write them by hand only for classes that cannot be records.
Example
When to use it
- Deduplicate subscriber emails in a HashSet regardless of case and surrounding spaces.
- Use an immutable tenant-and-page object as a cache key so lookups hit reliably.
- Log a configuration object at startup without printing its database password.
More examples
equals without hashCode
The two objects are equal but carry different identity hash codes, so the set looks in a different bucket, or skips the entry because the stored hash does not match, and never calls equals. Adding a hashCode based on code fixes it.
import java.util.HashSet;
import java.util.Set;
class Sku {
final String code;
Sku(String code) {
this.code = code;
}
@Override
public boolean equals(Object o) {
return o instanceof Sku other && code.equals(other.code);
}
// hashCode not overridden
}
void main() {
Sku a = new Sku("A-1");
Sku b = new Sku("A-1");
IO.println("equals: " + a.equals(b));
Set<Sku> seen = new HashSet<>();
seen.add(a);
IO.println("contains: " + seen.contains(b));
}
// Output:
// equals: true
// contains: falseChanging a key after inserting it
After the change the set computes a new hash and cannot find the very object it holds, yet the entry still counts towards its size. Keys should be immutable, which a record gives you for free.
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
class CacheKey {
String tenant;
int page;
CacheKey(String tenant, int page) {
this.tenant = tenant;
this.page = page;
}
@Override
public boolean equals(Object o) {
return o instanceof CacheKey k && page == k.page && Objects.equals(tenant, k.tenant);
}
@Override
public int hashCode() {
return Objects.hash(tenant, page);
}
}
void main() {
Set<CacheKey> keys = new HashSet<>();
CacheKey key = new CacheKey("acme", 1);
keys.add(key);
IO.println(keys.contains(key));
key.page = 2; // mutate a field used by hashCode
IO.println(keys.contains(key));
IO.println(keys.size());
}
// Output:
// true
// false
// 1A toString that hides secrets
Configuration objects get logged at startup and in error reports. Masking the password in toString means a careless log statement cannot leak it, which a record's generated toString would not do.
class DbConfig {
private final String url;
private final String user;
private final String password;
DbConfig(String url, String user, String password) {
this.url = url;
this.user = user;
this.password = password;
}
@Override
public String toString() {
return "DbConfig[url=" + url + ", user=" + user + ", password=****]";
}
}
void main() {
DbConfig config = new DbConfig("jdbc:postgresql://db:5432/shop", "shop_app", "not-a-real-secret");
IO.println("Starting with " + config);
}
// Output:
// Starting with DbConfig[url=jdbc:postgresql://db:5432/shop, user=shop_app, password=****]
Discussion