Immutable value boundaries: protect the edges

Trace shallow read-only fields and choose defensive-copy or immutable-view policies for nested mutable state. Relationships and copying · Object-oriented programming.

You hand a courier a list of delivery tags to attach to a parcel. Later you add one more tag to your own list, and the parcel's tags change too, though nobody touched the parcel. Marking a field read-only is not enough to stop that. What matters is who still holds an arrow into the list behind the field.

A fixed field can still reach a growing list

Start with a Parcel that holds a tracking code and a list of tags. Java records and Python's @dataclass(frozen=True) fix the field itself: after construction the field cannot be pointed at another list. They say nothing about the list it already points at, which is a separate object with its own add or append.

A frozen field, a growing list

Read the steps as text

  1. Parcel P0 is a frozen dataclass with a code and a tags field that reaches list L0, which holds one item. The variable tags also refers to L0.
  2. The program tries to reassign tags on Parcel P0, and the language refuses with FrozenInstanceError. The field is locked, so P0 keeps pointing at L0.
  3. The program adds "fragile" to list L0. The lock covers the field, not the list: L0 holds two items and the Parcel's arrow never moved.
  4. Two arrows reach L0, the caller's variable and the Parcel's field, so both see the new item.
  5. The program adds "gift" to list L0. The caller edits the same list directly, and the frozen Parcel now reaches three items.
  6. Only the Parcel's own field is fixed; the list it reaches stays open to every holder of an arrow into it.

The Python run below confirms both halves. Assigning a new list is refused, but appending to the old one works, and the caller's own variable sees the item too because it holds an arrow into the same list.

from dataclasses import dataclass, FrozenInstanceError

@dataclass(frozen=True)
class Parcel:
    code: str
    tags: list

tags = ["new"]
parcel = Parcel("P-17", tags)
parcel.tags.append("fragile")
print(parcel.tags, tags)   # ['new', 'fragile'] ['new', 'fragile']
try:
    parcel.tags = []
except FrozenInstanceError as e:
    print(type(e).__name__)  # FrozenInstanceError
record Parcel(String code, java.util.List<String> tags) {}

var input = new java.util.ArrayList<>(java.util.List.of("new"));
var parcel = new Parcel("P-17", input);
parcel.tags().add("fragile");
System.out.println(parcel.tags() + " " + input); // [new, fragile] [new, fragile]

Copy on the way in

The construction boundary is where the value decides whether to keep the caller's list or make its own. A record's generated constructor keeps the caller's list as it is. So when the caller adds an item afterward, the parcel changes with no method call on the parcel at all. That breaks encapsulation, and it can also change what the parcel equals or hashes to once it is a map key.

The fix is one line in the constructor: store a copy. In Java, List.copyOf builds a separate unmodifiable snapshot of the list. In Python, tuple(tags) builds a separate tuple, and a tuple has no append at all.

Copy on the way in

Read the steps as text

  1. Parcel P0 was built from the caller's list and kept it, so the variable input and P0 both reach ArrayList L0. A SafeParcel is built next, and the variable meter refers to Meter M0.
  2. The program adds "caller-only" to ArrayList L0. Parcel P0 ran no method, yet its list holds two items; the caller's arrow reaches that same list.
  3. The program builds a new SafeParcel S0. Variable safe now refers to SafeParcel S0. This SafeParcel starts with no list and no meter.
  4. The program copies ArrayList L0 as Snapshot L1. SafeParcel S0's tags link now points at Snapshot L1. The constructor stores a snapshot of what L0 holds right now; from here the two lists share nothing.
  5. SafeParcel S0's meter link now points at Meter M0. The Meter is kept, not copied: the service owns it, so one object serves both holders.
  6. The program adds "late" to ArrayList L0. Parcel P0 sees the new item through the list it kept, and the snapshot behind S0 stays at two items.
  7. The program changes the count of Meter M0 from 0 to 1. S0 and the variable meter both reach M0, so both see the new count; sharing is the point of a Meter.
  8. One list was copied at the boundary and one Meter was shared on purpose; the constructor decided both.

In both programs the caller's later item never reaches the safe parcel; the Java run also prints the unsafe Parcel for contrast. The copy covers the list the parcel owns. The scene also keeps a Meter the whole service counts into; that one is shared on purpose, so it is deliberately not copied.

record Parcel(String code, java.util.List<String> tags) {}
record SafeParcel(String code, java.util.List<String> tags) {
    SafeParcel { tags = java.util.List.copyOf(tags); }
}

var a = new java.util.ArrayList<>(java.util.List.of("new"));
var kept = new Parcel("P-17", a);
a.add("caller-only");
System.out.println(kept.tags()); // [new, caller-only]

var b = new java.util.ArrayList<>(java.util.List.of("new"));
var safe = new SafeParcel("P-17", b);
b.add("caller-only");
System.out.println(safe.tags() + " " + b); // [new] [new, caller-only]
from dataclasses import dataclass

@dataclass(frozen=True)
class SafeParcel:
    code: str
    tags: tuple

    @classmethod
    def from_tags(cls, code, tags):
        return cls(code, tuple(tags))

given = ["new"]
safe = SafeParcel.from_tags("P-17", given)
given.append("caller-only")
print(safe.tags, given)   # ('new',) ['new', 'caller-only']

A copy of the list is shallow about what the list holds. Strings cannot be edited in place, so sharing them is harmless. If the items were editable objects, every one would still be shared with the caller, and the copy would need to go one level deeper.

Choose what leaves the box

Construction is half the boundary. An accessor that returns the stored list reopens the same hole in the other direction: any caller can write into private state. A box has three honest things it can return.

A snapshot is a separate unmodifiable list, so later changes by the owner never reach it. A view is an unmodifiable wrapper that reads through to the owner's list: callers cannot write, but they see every later change. Returning the field itself lets callers write into private state and see every later change. "Snapshot" and "View" are role names for these two kinds of list, not Java class names.

What leaves the box

Read the steps as text

  1. ParcelBox B0 holds ArrayList L0 with one item. Callers will see it through a snapshot, a read-only view and, finally, the field itself.
  2. The program copies ArrayList L0 as Snapshot L1. Variable snap now refers to Snapshot L1. The snapshot is a separate list, so nothing the box does to L0 later will reach it.
  3. The program wraps ArrayList L0 as View V0, which reads through to it. Variable view now refers to View V0. Nothing was copied: the view is a read-only window onto L0, so it will show whatever L0 holds later.
  4. The program tries to add an item to View V0, and the language refuses with UnsupportedOperationException. A caller holding the view cannot change the box's list.
  5. The program adds "late" to ArrayList L0. L0 grew once; the view reaches the new item and the snapshot still holds one.
  6. Variable leak now refers to ArrayList L0. Returning the field hands the caller the box's own list, not a copy or a view.
  7. The program adds "injected" to ArrayList L0. The caller wrote into the box's private list, and the view shows the injected item too.
  8. The snapshot stayed fixed, the view followed L0, and only a caller holding the leaked arrow could write.

The output below shows each choice after the owner adds late and a leaked reference adds injected.

import java.util.*;

final class ParcelBox {
    private final List<String> tags = new ArrayList<>(List.of("new"));
    List<String> snapshot() { return List.copyOf(tags); }
    List<String> view() { return Collections.unmodifiableList(tags); }
    List<String> field() { return tags; }
    void add(String tag) { tags.add(tag); }
}

var box = new ParcelBox();
var snap = box.snapshot();
var view = box.view();
box.add("late");
box.field().add("injected");
System.out.println(snap); // [new]
System.out.println(view); // [new, late, injected]
try {
    view.add("x"); // the view refuses every write
} catch (UnsupportedOperationException e) {
    System.out.println("refused"); // refused
}
class ParcelBox:
    def __init__(self):
        self._tags = ["new"]

    def snapshot(self):
        return tuple(self._tags)   # separate; later edits never reach it

    def field(self):
        return self._tags          # the box's own list

box = ParcelBox()
snap = box.snapshot()
box.field().append("late")
print(snap, box.field())   # ('new',) ['new', 'late']

Python's standard library has no read-only live view of a list, so a Python box chooses between a snapshot and the field itself.

Choose the boundary from what callers must not do

Ask what callers are forbidden to do, then pick the cheapest boundary that enforces it. If callers must not change the value, copy on the way in and return a snapshot or a view. If callers need to watch the owner's later changes, return a view. If the item is a shared resource such as a counter, a connection or a cache that the whole service uses, keep one shared copy and let the lifecycle owner manage it; duplicating it would make two meters that disagree. Do not freeze everything by habit, and do not copy a resource whose identity is the point.

Take a pricing table loaded once at startup and read by every request handler. The loader copies the rows in, so the file reader's buffer cannot rewrite them later. The table hands handlers a view or an immutable list, so one buggy handler cannot change the prices the next request sees. The database connection pool beside it is never copied: it is a shared resource, and handlers borrow it. Each choice costs a line of code at the boundary, and each one is made where the data crosses it.

Recap

  • Final fields, records and frozen dataclasses fix which list a field points at, not what the list holds.
  • Copy a mutable input in the constructor when the value must own stable state, and keep deliberately shared resources shared.
  • Return a snapshot when callers should not see later changes, a view when they should, and never the field when they must not write.
Go deeper

List.copyOf rejects null elements and may return its argument unchanged when that argument is already an immutable copy; Collections.unmodifiableList accepts nulls and wraps a modifiable list. Both rely on element behavior, so a list of mutable labels stays editable through either boundary.

Java's value-based class guidance describes a category of identity-free classes; it does not designate every record that way. Python's frozen dataclass is likewise a field-assignment policy, and a frozen dataclass holding a list is unhashable, because hashing a list-holding value would let the hash change. State the boundary in the constructor and the accessor rather than advertising "deeply immutable" as a universal property.

Continue to shallow and deep copy when the required boundary is a mutable object graph. Practise encapsulated state in Linked List Fundamentals, where a link API must control which nodes callers can reach.