2.2 Immutability, Representation Leaks, and Equality
In the Workshop Council Chamber, a component that appeared to be properly encapsulated is quietly corrupted by external code. Ah Hua begins tracing where the representation has leaked.
Encapsulation does not mean writing fields as private. As long as callers can modify an object's internal state through references, the representation is leaking. Conversely, a truly immutable ADT must strictly control construction inputs, internal updates, and returned values.
final Only Fixed References
The following class appears immutable: its fields are private final, and there are no setters.
public final class Playlist {
private final List<String> tracks;
public Playlist(List<String> tracks) {
this.tracks = tracks;
}
public List<String> tracks() {
return tracks;
}
}However, both the constructor parameters and return values point to the same mutable list:
List<String> source = new ArrayList<>(List.of("A"));
Playlist playlist = new Playlist(source);
source.add("B"); // Modify internal state from construction entry point
playlist.tracks().clear(); // Modify internal state from accessorfinal only guarantees that the tracks field will not point to a different list afterward; it does not guarantee that the contents of the list remain unchanged.
Cut All Alias Paths to Mutable References
import java.util.List;
import java.util.Objects;
public final class Playlist {
private final List<String> tracks;
public Playlist(List<String> tracks) {
Objects.requireNonNull(tracks, "tracks");
this.tracks = List.copyOf(tracks);
}
public List<String> tracks() {
return tracks;
}
public Playlist append(String track) {
Objects.requireNonNull(track, "track");
var next = new java.util.ArrayList<>(tracks);
next.add(track);
return new Playlist(next);
}
}List.copyOf Create an immutable result and reject null elements. Since the alias connection to the original list is severed at construction, accessors can safely return internal references; however, callers still cannot modify the result.
Immutable collections do not imply deep immutability
If the elements within a list are mutable, callers may still alter observable state through references to those elements. Deep immutability requires that all reachable objects in the object graph are immutable, or that sufficient deep copying occurs at boundaries.
Collections.unmodifiableList(source) Only provide an immutable view; if other parts of the system still hold references to source, underlying changes will propagate through the view. For snapshot semantics, copying is required, never just wrapping.
Engineering Benefits of Immutability
Immutable objects are typically easier to:
- Maintain referential integrity: once constructed, their state never changes;
- Share safely: no data races or intermediate states arise;
- Serve as map keys: hash values remain stable even if field values change;
- Test and reason about: the same reference cannot be silently modified elsewhere.
This does not mean every object should be immutable. Connections, caches, and aggregate roots naturally involve state changes. The key is to concentrate state changes within well-defined boundaries and avoid sharing mutable aliases.
First Decide What It Means to Be the Same
Before implementing equals, you must choose an equivalence relation.
| Type | Common Semantics | Examples |
|---|---|---|
| Value Object | Equal if their contents are identical | Amount, coordinates, rational numbers |
| Entity | Considered the same entity if it has the same stable identifier | Order, account |
| In-Process Resource | Typically identified by object identity | Thread, lock, connection handle |
Do not automatically derive equality based on fields appearing similar. For example, two orders may have the same recipient and total price, but that does not mean they are the same order.
equals and hashCode Are a Joint Contract
Java's equals must be reflexive, symmetric, transitive, and consistent, and must return false for null. Additionally:
a.equals(b) == true => a.hashCode() == b.hashCode()The reverse does not hold; different objects can have the same hash value.
Value objects can enable records to generate component-based equality, but input normalization must still be performed first:
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Objects;
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
amount = amount.stripTrailingZeros();
}
}This normalization ensures that 10.0 and 10.00 have identical components. A real currency model must also define rules for decimal precision, rounding, and cross-currency arithmetic; records do not perform domain design for you.
Why Mutable Objects Are Unsuitable as Hash Keys
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey("A");
map.put(key, "value");
key.setCode("B");
map.get(key); // May not be found: object’s bucket is outdated hash decideAny field participating in equals or hashCode that changes after being inserted into a hash container violates the hash container's invariant. A safer approach is to use immutable keys or stable IDs as keys.
Inheritance Makes Value Equality Harder
If a parent class uses instanceof, and a child class adds fields involved in equality comparison, it's easy to break symmetry: the parent may consider two objects equal, while the child considers them unequal. Value types generally benefit from immutable composition and closed implementations. If extensibility is truly needed, equivalence should be explicitly defined in the public specification, and tests should cover comparisons across different implementations.
Steps to Choose Mutability and Equality
- Determine whether the type is a value, an entity, or a resource handle;
- Specify the abstract value observable by callers;
- List all entry and exit paths for mutable references;
- Decide on snapshot, read-only view, or controlled mutation semantics;
- If overriding
equals, also overridehashCode; - Test the equivalence laws and verify actual behavior within collections.
Exercise: Review a Time Interval Type
Design an ADT for TimeRange requiring the interval to be [start, end):
- What types should be used for
startandend? - Is an empty interval valid?
- How should
containsandoverlapsbe expressed? - When do two intervals equal each other?
- If endpoints are mutable, what RI, hashing, and concurrency issues arise?
First write the specification, abstract interpretation (AF), and representation invariant (RI), then decide on the fields. Reversing this order often results in fields unintentionally becoming part of the public contract.
References
- [Java SE 21:
List.copyOf](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html#copyOf(java.util.Collection) - [Java SE 21:
Collections.unmodifiableList](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Collections.html#unmodifiableList(java.util.List) - Java SE 21:
Object.equalsandhashCode - Java Language Specification: Record Classes