Skip to content

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.

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

java
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 accessor

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

java
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.

TypeCommon SemanticsExamples
Value ObjectEqual if their contents are identicalAmount, coordinates, rational numbers
EntityConsidered the same entity if it has the same stable identifierOrder, account
In-Process ResourceTypically identified by object identityThread, 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:

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

java
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

java
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 decide

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

  1. Determine whether the type is a value, an entity, or a resource handle;
  2. Specify the abstract value observable by callers;
  3. List all entry and exit paths for mutable references;
  4. Decide on snapshot, read-only view, or controlled mutation semantics;
  5. If overriding equals, also override hashCode;
  6. 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 start and end?
  • Is an empty interval valid?
  • How should contains and overlaps be 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

Built with VitePress | Software Systems Atlas