Immutability — Practice Tasks¶
Twelve exercises that turn the chapter's rules into muscle memory. Each task gives you leaky, mutable code in Go, Java, or Python, a clear instruction, and a full solution behind a collapsible block. Read the problem, write your own version, then open the solution and compare reasoning — not just syntax. Difficulty climbs from spotting a single mutation to building copy-on-write and map-safe value types.
Table of Contents¶
- Task 1 — Stop mutating a method argument (Python, Easy)
- Task 2 — Stop returning a mutable reference to internal state (Java, Easy)
- Task 3 — Pure normalization without touching the input (Go, Easy)
- Task 4 — Fix partial immutability: the leaky inner collection (Java, Medium)
- Task 5 — Replace setters with a
with/copy update (Python, Medium) - Task 6 — Make a value object a proper immutable type (Go, Medium)
- Task 7 — Fix aliasing escape through the constructor (Java, Medium)
- Task 8 — Defensive copy out and in, for a date range (Java, Medium)
- Task 9 — Implement copy-on-write for a small collection (Go, Hard)
- Task 10 — Make an object safe as a map key (Python, Hard)
- Task 11 — Deep-freeze a nested config tree (Python, Hard)
- Task 12 — Immutability audit (Java — open-ended)
- Self-Assessment
- Related Topics
How to Use¶
- Read the problem and the code. Identify exactly which mutation rule is broken before writing anything: an argument being mutated, an internal reference escaping, a setter, or an aliasing leak.
- Write your own fix first. Type it out in a real file and, where possible, run it. The solutions assume you have already struggled with the problem.
- Open the solution and compare reasoning, not just code. The "Why" notes matter more than the exact lines. Two correct solutions can differ in style; the invariant they protect is the same.
- Watch the difficulty curve. Tasks 1–3 isolate one rule each. Tasks 4–8 combine copy-out and copy-in. Tasks 9–12 ask you to design immutable types and reason about deep structure.
The decision each task forces is always the same one:
Task 1 — Stop mutating a method argument (Python, Easy)¶
Scenario: A reporting helper appends a footer row to the rows it is handed before rendering. A caller reuses the same rows list for an on-screen table and a PDF export, and is baffled when the PDF footer shows up in the table too.
def render_report(rows: list[dict]) -> str:
rows.append({"label": "TOTAL", "value": sum(r["value"] for r in rows)})
return "\n".join(f'{r["label"]}: {r["value"]}' for r in rows)
# Caller:
data = [{"label": "Jan", "value": 10}, {"label": "Feb", "value": 20}]
pdf = render_report(data)
# data now has a phantom TOTAL row the caller never added.
Instruction: Make render_report a pure function of its argument. It must not modify rows.
Solution
**Why:** A function that takes a collection should treat it as read-only unless its name screams otherwise (`append_total_in_place`). The fix builds a *new* list with `[*rows, total]` and leaves the caller's `rows` alone. The phantom-row bug disappears because the only thing that ever held the footer is the local `all_rows`, which dies when the function returns. Note we still mutate nothing the caller can see, but we did *not* deep-copy the dicts — and we did not need to, because we never write to them. Copy only what you actually modify.Task 2 — Stop returning a mutable reference to internal state (Java, Easy)¶
Scenario: A ShoppingCart exposes its line items so the UI can render them. A bug report says items vanish from carts at random. The culprit: a view component calls cart.getItems().clear() to reset its own display.
class ShoppingCart {
private final List<Item> items = new ArrayList<>();
public void add(Item item) { items.add(item); }
public List<Item> getItems() {
return items; // hands out the live internal list
}
}
Instruction: Stop leaking the internal list. Callers must be able to read items but must not be able to mutate the cart through the getter.
Solution
**Why:** `Collections.unmodifiableList` returns a wrapper that throws `UnsupportedOperationException` on `add`, `remove`, and `clear`. The `view.clear()` call now fails loudly at the offending line instead of silently corrupting a different object. This is an *unmodifiable view*, not a copy: it is O(1) and still reflects later additions made through `add`. That is usually what you want for a getter. Reach for `List.copyOf(items)` (a true snapshot) only when the caller must see a stable list even as the cart keeps changing.Task 3 — Pure normalization without touching the input (Go, Easy)¶
Scenario: A function normalizes tag strings (trim, lowercase) for a search index. It mutates the slice in place, so the original tags shown in the UI silently become lowercased too — slices share their backing array.
package tags
import "strings"
func Normalize(tags []string) []string {
for i := range tags {
tags[i] = strings.ToLower(strings.TrimSpace(tags[i])) // mutates caller's slice
}
return tags
}
Instruction: Return a normalized copy and leave the caller's slice element values untouched.
Solution
**Why:** In Go a `[]string` is a header pointing at a shared backing array. Writing `tags[i] = ...` reaches through that pointer and changes what the caller sees. Allocating `out := make([]string, len(tags))` and writing into it keeps the input read-only. `copy(out, tags)` followed by an in-place loop over `out` would work too, but here a single allocating loop is clearer. The rule: if you receive a slice and intend to transform it, transform a copy unless the function name documents in-place mutation.Task 4 — Fix partial immutability: the leaky inner collection (Java, Medium)¶
Scenario: A Team was "made immutable" — the class is final, the field is final, there are no setters. Yet a caller still manages to add a member after construction. The immutability is only skin-deep: the field reference is final, but the list it points to is not.
final class Team {
private final String name;
private final List<String> members;
Team(String name, List<String> members) {
this.name = name;
this.members = members; // stores the caller's list
}
public List<String> getMembers() { return members; } // and hands it back
}
// Caller:
List<String> roster = new ArrayList<>(List.of("Ana"));
Team t = new Team("A", roster);
roster.add("Mallory"); // mutates the team from outside
t.getMembers().add("Eve"); // ...and from the getter
Instruction: Make Team genuinely immutable. Close both leaks: the constructor alias and the getter.
Solution
import java.util.List;
final class Team {
private final String name;
private final List<String> members;
Team(String name, List<String> members) {
this.name = name;
this.members = List.copyOf(members); // copy IN: defensive snapshot
}
public List<String> getMembers() {
return members; // copyOf already returned an immutable list
}
}
Task 5 — Replace setters with a with/copy update (Python, Medium)¶
Scenario: A UserProfile is passed around the app and read from many threads. It exposes setters "for convenience." Now profile fields change under code that assumed a stable snapshot, and there is no audit trail of what changed when.
class UserProfile:
def __init__(self, name: str, email: str, theme: str):
self.name = name
self.email = email
self.theme = theme
def set_email(self, email: str) -> None:
self.email = email
def set_theme(self, theme: str) -> None:
self.theme = theme
Instruction: Make UserProfile immutable and replace the setters with copy-on-update (with_*) methods that return a new profile, leaving the original unchanged.
Solution
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class UserProfile:
name: str
email: str
theme: str
def with_email(self, email: str) -> "UserProfile":
return replace(self, email=email)
def with_theme(self, theme: str) -> "UserProfile":
return replace(self, theme=theme)
# Usage:
p1 = UserProfile("Ana", "ana@old.com", "light")
p2 = p1.with_email("ana@new.com")
# p1 is untouched; p2 is a new object that differs in one field.
assert p1.email == "ana@old.com"
assert p2.email == "ana@new.com"
Task 6 — Make a value object a proper immutable type (Go, Medium)¶
Scenario: Money is modeled with exported fields and a mutating Add. Two bugs follow: callers mutate Amount directly, and Add changes the receiver, so a.Add(b) silently alters a.
package money
type Money struct {
Amount int64 // minor units (cents)
Currency string
}
func (m *Money) Add(other Money) {
m.Amount += other.Amount // mutates the receiver in place
}
Instruction: Turn Money into an immutable value type. Operations must return a new Money; the receiver must never change. Hide the fields behind a constructor that validates.
Solution
package money
import "fmt"
// Money is an immutable value type. Fields are unexported, so the only way
// to obtain one is through New, and the only way to "change" it is to derive
// a new value.
type Money struct {
amount int64 // minor units
currency string
}
func New(amount int64, currency string) (Money, error) {
if currency == "" {
return Money{}, fmt.Errorf("currency required")
}
return Money{amount: amount, currency: currency}, nil
}
func (m Money) Amount() int64 { return m.amount }
func (m Money) Currency() string { return m.currency }
// Add returns a NEW Money; the receiver is a value, not a pointer, so it
// cannot be mutated even by accident.
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, fmt.Errorf("currency mismatch: %s vs %s", m.currency, other.currency)
}
return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}
Task 7 — Fix aliasing escape through the constructor (Java, Medium)¶
Scenario: An Order stores a Customer whose internal tags set is mutable. The Order constructor stores the passed-in Customer, but a deeper problem hides: the array of line items handed to the constructor is kept by reference, and the caller keeps mutating it after construction.
final class Order {
private final String id;
private final LineItem[] items;
Order(String id, LineItem[] items) {
this.id = id;
this.items = items; // keeps the caller's array
}
public int lineCount() { return items.length; }
}
// Caller:
LineItem[] lines = { new LineItem("A", 1) };
Order order = new Order("o-1", lines);
lines[0] = new LineItem("HACKED", 999); // reaches inside the order
Instruction: Stop the aliasing escape. The order must capture the items at construction time so later mutation of the caller's array has no effect.
Solution
import java.util.Arrays;
final class Order {
private final String id;
private final LineItem[] items;
Order(String id, LineItem[] items) {
this.id = id;
this.items = Arrays.copyOf(items, items.length); // defensive copy IN
}
public int lineCount() { return items.length; }
public LineItem itemAt(int i) { return items[i]; } // never expose the array itself
}
Task 8 — Defensive copy out and in, for a date range (Java, Medium)¶
Scenario: A DateRange predates java.time and uses the legacy, mutable java.util.Date. The range stores the dates it is given and returns them through getters. Both ends leak: a caller can mutate a Date it passed in, or mutate one it got back.
import java.util.Date;
final class DateRange {
private final Date start;
private final Date end;
DateRange(Date start, Date end) {
this.start = start; // stored by reference
this.end = end;
}
public Date getStart() { return start; } // leaked by reference
public Date getEnd() { return end; }
}
Instruction: Make DateRange immutable even though Date is mutable. Defend both the constructor and the getters.
Solution
import java.util.Date;
final class DateRange {
private final Date start;
private final Date end;
DateRange(Date start, Date end) {
if (end.before(start)) {
throw new IllegalArgumentException("end before start");
}
this.start = new Date(start.getTime()); // copy IN
this.end = new Date(end.getTime());
}
public Date getStart() { return new Date(start.getTime()); } // copy OUT
public Date getEnd() { return new Date(end.getTime()); }
}
Task 9 — Implement copy-on-write for a small collection (Go, Hard)¶
Scenario: A Registry holds a small set of feature flags read on every request and updated rarely. Reads vastly outnumber writes. You want readers to never see a half-applied update and never need a lock, while writes stay correct.
package registry
// Naive: every read and write touches the same map, so concurrent access
// races and a reader can observe a partially mutated map.
type Registry struct {
flags map[string]bool
}
func (r *Registry) Get(name string) bool { return r.flags[name] }
func (r *Registry) Set(name string, on bool) { r.flags[name] = on }
Instruction: Implement copy-on-write: Set produces a brand-new map and swaps it in atomically; readers operate on an immutable snapshot. Each Set returns a new Registry so callers can keep an old snapshot if they want.
Solution
package registry
// Registry is immutable. The map it points to is never mutated after a
// Registry is constructed; Set returns a new Registry with a new map.
type Registry struct {
flags map[string]bool // treated as read-only once stored
}
func New(initial map[string]bool) *Registry {
cp := make(map[string]bool, len(initial))
for k, v := range initial {
cp[k] = v // copy IN so the caller's map cannot mutate ours
}
return &Registry{flags: cp}
}
func (r *Registry) Get(name string) bool {
return r.flags[name] // safe: the map is never written after construction
}
// Set is copy-on-write: build a full new map, change one entry, return a new
// Registry. The old Registry and its map are untouched, so any reader holding
// the old *Registry continues to see a consistent snapshot.
func (r *Registry) Set(name string, on bool) *Registry {
next := make(map[string]bool, len(r.flags)+1)
for k, v := range r.flags {
next[k] = v
}
next[name] = on
return &Registry{flags: next}
}
Task 10 — Make an object safe as a map key (Python, Hard)¶
Scenario: A pathfinding cache keys results by a Point. The current Point is a plain mutable class, so it is unhashable — and even after someone bolts on __hash__, a Point whose coordinates are mutated after insertion becomes unfindable in the dict (its hash no longer matches its bucket).
class Point:
def __init__(self, x: int, y: int):
self.x = x
self.y = y
# Desired but broken:
cache: dict[Point, list] = {}
p = Point(1, 2)
cache[p] = ["route..."] # TypeError: unhashable type: 'Point'
Instruction: Make Point usable as a dictionary key. It must be hashable, compare by value, and be immutable so its hash can never drift after insertion.
Solution
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
# Now this works and stays correct:
cache: dict[Point, list] = {}
p = Point(1, 2)
cache[p] = ["route..."]
assert cache[Point(1, 2)] == ["route..."] # value equality finds it
# p.x = 9 -> raises FrozenInstanceError: hash can never drift
Task 11 — Deep-freeze a nested config tree (Python, Hard)¶
Scenario: A configuration is loaded once at startup and shared globally. It is a nested structure of dicts and lists. A @dataclass(frozen=True) wrapper protects the top level, but the nested dict/list values stay mutable — any module can reach in and rewrite the live config (config.values["db"]["pool"] = 999).
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
values: dict # frozen reference, but the dict itself is mutable!
cfg = Config({"db": {"pool": 10}, "features": ["a", "b"]})
cfg.values["db"]["pool"] = 999 # mutates global config; no error
Instruction: Deep-freeze the config so the entire tree is immutable — nested dicts become read-only mappings and nested lists become tuples — while top-level access stays ergonomic.
Solution
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Mapping
def deep_freeze(value: Any) -> Any:
if isinstance(value, Mapping):
return MappingProxyType({k: deep_freeze(v) for k, v in value.items()})
if isinstance(value, (list, tuple)):
return tuple(deep_freeze(v) for v in value)
if isinstance(value, set):
return frozenset(deep_freeze(v) for v in value)
return value # str/int/float/bool/None are already immutable
@dataclass(frozen=True)
class Config:
values: Mapping
def __init__(self, values: Mapping):
object.__setattr__(self, "values", deep_freeze(values))
cfg = Config({"db": {"pool": 10}, "features": ["a", "b"]})
assert cfg.values["db"]["pool"] == 10
assert cfg.values["features"] == ("a", "b")
# cfg.values["db"]["pool"] = 999
# -> TypeError: 'mappingproxy' object does not support item assignment
Task 12 — Immutability audit (Java — open-ended)¶
Scenario: Below is a class that was reviewed as "immutable enough." It is not. Find every way its internal state can be mutated from outside, and state the fix for each.
import java.util.Date;
import java.util.List;
import java.util.Map;
final class Account {
private final String id;
private Date openedOn; // (a)
private final List<String> roles; // (b)
private final Map<String, String> metadata; // (c)
private final int[] dailyLimits; // (d)
Account(String id, Date openedOn, List<String> roles,
Map<String, String> metadata, int[] dailyLimits) {
this.id = id;
this.openedOn = openedOn; // stored by reference
this.roles = roles; // stored by reference
this.metadata = metadata; // stored by reference
this.dailyLimits = dailyLimits; // stored by reference
}
public Date getOpenedOn() { return openedOn; } // leaked
public List<String> getRoles() { return roles; } // leaked
public Map<String, String> getMeta(){ return metadata; } // leaked
public int[] getDailyLimits() { return dailyLimits; }// leaked
public void setOpenedOn(Date d) { this.openedOn = d; } // setter!
}
Solution
| # | Leak | Fix | |---|------|-----| | (a) | `openedOn` is non-`final` and has a `setOpenedOn` setter; also it is a mutable `Date`. | Make the field `final`, delete the setter, and either copy in/out (`new Date(d.getTime())`) or switch to `java.time.Instant`, which is immutable and needs no copying. | | (b) | `roles` is stored and returned by reference, so callers mutate the list both ways. | Copy in with `List.copyOf(roles)` in the constructor; return it directly (it is already unmodifiable). | | (c) | `metadata` is stored and returned by reference. | Copy in with `Map.copyOf(metadata)`; return the immutable copy from `getMeta()`. | | (d) | `dailyLimits` is a mutable array, aliased on the way in and leaked on the way out. | `this.dailyLimits = Arrays.copyOf(dailyLimits, dailyLimits.length)` in the constructor; `return Arrays.copyOf(dailyLimits, dailyLimits.length)` (or expose an accessor like `limitAt(int)`) from the getter. | | general | The class is `final` (good) but leaks through every reference field. | After the per-field fixes, no setter remains, every field is `final`, every mutable component is copied in and copied out (or replaced with an immutable type). | **Corrected version:**import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
final class Account {
private final String id;
private final Instant openedOn; // immutable type, no copying
private final List<String> roles; // unmodifiable snapshot
private final Map<String, String> metadata; // unmodifiable snapshot
private final int[] dailyLimits; // copied in; copied out below
Account(String id, Instant openedOn, List<String> roles,
Map<String, String> metadata, int[] dailyLimits) {
this.id = id;
this.openedOn = openedOn;
this.roles = List.copyOf(roles);
this.metadata = Map.copyOf(metadata);
this.dailyLimits = Arrays.copyOf(dailyLimits, dailyLimits.length);
}
public Instant getOpenedOn() { return openedOn; }
public List<String> getRoles() { return roles; }
public Map<String, String> getMeta() { return metadata; }
public int[] getDailyLimits() { return Arrays.copyOf(dailyLimits, dailyLimits.length); }
// no setter; to "change" an account, build a new one (see with-style updates, Task 5)
}
Self-Assessment¶
Rate yourself honestly. You have internalized this chapter when you can:
- Spot an argument-mutating function on sight and rewrite it to return a new value (Tasks 1, 3).
- Explain why
final/readonlyon a field does not make the referenced object immutable (Tasks 4, 12). - Close both boundaries — copy in at the constructor and copy out at the getter — and say which mutable types force you to do both (Tasks 7, 8).
- Choose correctly between an unmodifiable view (
Collections.unmodifiableList) and a true copy (List.copyOf), and justify the choice (Tasks 2, 4). - Replace setters with
with/copy-update methods and explain the snapshot guarantee that buys callers (Task 5). - Design a value type with no language
finalkeyword available, using unexported fields and value receivers (Task 6). - Implement copy-on-write and state the read/write cost trade-off it makes (Task 9).
- List the two requirements for a safe map key and connect them to immutability (Task 10).
- Deep-freeze a nested structure and explain why a top-level freeze is not enough (Task 11).
If any box is unchecked, redo that task from scratch without looking at the solution.
Related Topics¶
- Immutability — chapter README — the positive rules these tasks invert.
- junior.md — beginner-level definitions and the harm each anti-pattern causes.
- find-bug.md — buggy snippets where mutation issues hide.
- optimize.md — when defensive copying costs too much and what to do about it.
- Functional Programming — immutability is the backbone of pure functions and persistent data structures.
- Refactoring — techniques such as Encapsulate Collection and Change Reference to Value that get you to immutability safely.
In this topic