2.2 Immutability, Representation Leaks, and Equality
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 Fixes the Reference
Use Java 21. The two Playlist classes are alternative implementations: compile them separately or replace the first with the second. Do not keep both in one project. The first appears immutable because its fields are private final and it has no setters.
import java.util.List;
public final class Playlist {
private final List<String> tracks;
public Playlist(List<String> tracks) {
this.tracks = tracks;
}
public List<String> tracks() {
return tracks;
}
}This method-body fragment requires java.util.List, ArrayList, and the first Playlist class. The input and accessor share one 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 accessorAfter source.add, tracks is [A, B]. After clear, both source and tracks are empty. Both changes bypass Playlist business methods.
final 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
Replace Playlist.java with this complete class. The append method copies the data and returns a new value explicitly:
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 returns an unmodifiable snapshot and rejects null elements. It may reuse a suitable unmodifiable input, so it does not promise a fresh allocation. Since the elements here are immutable Strings, the accessor can return the list directly. Mutating source no longer changes it; tracks().clear() throws UnsupportedOperationException.
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 reachable objects to be immutable too. If the implementation uses mutable elements, preserving the ADT's observable value requires sufficient copying at both input and output boundaries and control of internal mutation. Copying only the list container is insufficient.
Collections.unmodifiableList(source) provides an unmodifiable 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 representation invariants: once constructed, their state never changes;
- Share safely: no data races or intermediate states arise;
- Serve as map keys: equality-relevant state and its hash remain stable;
- 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.
This Money uses Currency and is a separate value-type example. In the 1.1 practice project, replace the earlier Money.java instead of keeping two classes with the same name. Records generate component-based equality; first decide whether decimal scale is part of the amount's value:
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();
}
}BigDecimal.equals compares both numerical value and scale, so raw 10.0 and 10.00 differ even though compareTo returns zero. This model ignores trailing zeros: normalized Money values in the same currency have equal hashes and occupy one HashSet entry. stripTrailingZeros may produce a negative scale, such as 1E+1 for 10.0; define display formatting separately. BigDecimal contract 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
Save this complete example as HashKeyDemo.java, then run javac --release 21 HashKeyDemo.java and java HashKeyDemo. It should print null followed by 1: the entry remains stored, but lookup uses the changed hash. This type deliberately demonstrates a mutable-key defect.
import java.util.HashMap;
import java.util.Map;
public final class HashKeyDemo {
static final class MutableKey {
private String code;
MutableKey(String code) { this.code = code; }
void setCode(String code) { this.code = code; }
@Override public int hashCode() { return code.hashCode(); }
@Override public boolean equals(Object other) {
return other instanceof MutableKey key && code.equals(key.code);
}
}
public static void main(String[] args) {
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey("A");
map.put(key, "value");
key.setCode("B");
System.out.println(map.get(key));
System.out.println(map.size());
}
}Any 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?
One checkable choice uses Instant, requires start <= end, and allows empty intervals. The comparisons below are pseudocode; Java code should call Instant comparison methods:
isEmpty = start == end
contains(t) = start <= t && t < end
overlaps(other) = !isEmpty && !other.isEmpty
&& start < other.end && other.start < endFor example, [1, 3) and [3, 5) do not overlap. The empty interval [2, 2) overlaps nothing; using only the two cross-endpoint comparisons can incorrectly report an overlap. For calendar dates, choose LocalDate and retain explicit boundary rules.
First write the specification, abstraction function (AF), and representation invariant (RI), then decide on the fields. Reversing this order often results in fields unintentionally becoming part of the public contract.