Skip to content

VOL 06 / CH 02 / LESSON 02

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.

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

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

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

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

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.

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:

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();
    }
}

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.

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

  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?

One checkable choice uses Instant, requires start <= end, and allows empty intervals. The comparisons below are pseudocode; Java code should call Instant comparison methods:

text
isEmpty = start == end
contains(t) = start <= t && t < end
overlaps(other) = !isEmpty && !other.isEmpty
                  && start < other.end && other.start < end

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

References ​

Built with VitePress | Software Systems Atlas