all_lessons/functional programming/03 · Immutabilitylesson 4 / 15

Immutability: state, identity, and history

Lesson 02 gave you the syntax — val for a binding that cannot be reassigned, case class for a data record, and .copy to produce a changed version. Now we cash that syntax out into a principle. This is the first of the three pillars (03 immutable data, 04 pure functions, 05 functions as values). The one new idea: when a value can never change in place, an entire category of bug — the one where two parts of your program edit the same object behind each other's backs — simply cannot occur. We will see that bug happen, see immutability make it impossible, and end by naming the cost worry that Lesson 12 will resolve.

Book source
This maps to the book's treatment of immutability as the first concrete discipline against complexity — the move from "a variable is a box you keep changing" to "a value is a fact that never changes." We follow that arc (aliasing, value-vs-identity, copy-on-write, the concurrency payoff, the cost objection) but the bugs, traces, and Scala here are original.
Linear position
Prerequisite: Lesson 02 — you can read val, case class, and .copy(field = newValue), and you saw in Lesson 01 the substitution model ("replace an expression with its value").
New capability: explain why immutable data removes aliasing bugs, distinguish a value from its identity, perform copy-on-write updates that leave the old value intact (a free history), and state honestly the cost worry that Lesson 12 answers.
The plan
Five moves. (1) Watch the aliasing bug happen — two references to one mutable object, a helper that mutates it, action-at-a-distance — then watch immutability make it impossible. (2) Separate value from identity and see why immutability makes identity stop mattering. (3) Copy-on-write: how you "update" without mutating, and why the old value survives as free history. (4) The concurrency payoff (a preview of Lesson 13): an immutable value is shareable across threads with zero locking. (5) The honest cost worry — "doesn't building a new value copy everything?" — answered in intuition now, mechanism promised for Lesson 12.

1 · The aliasing bug, concretely

An alias is a second name for the same object. With mutable data, aliases are a trap: change the object through one name and every other name sees the change, often far from where you were looking. Here is a mutable cart built on scala.collection.mutable.ListBuffer, plus an innocent-looking helper.

import scala.collection.mutable.ListBuffer

// a helper that "just looks at" the cart to compute a preview total
def previewWithFreebie(cart: ListBuffer[String]): Int = {
  cart += "free-gift"        // BUG: mutates the caller's cart in place
  cart.size
}

val myCart = ListBuffer("book", "pen")
val alsoMyCart = myCart      // NOT a copy — a second name for the SAME buffer

val n = previewWithFreebie(myCart)   // n == 3
println(myCart)       // ListBuffer(book, pen, free-gift)  -- changed!
println(alsoMyCart)   // ListBuffer(book, pen, free-gift)  -- ALSO changed!

Three names — the parameter cart, the binding myCart, and the alias alsoMyCart — all point at one buffer. The helper was only supposed to preview a total, but its += edited that one shared buffer, so the free gift now appears everywhere. The damage shows up at println(alsoMyCart), a line that never called the helper. That is action-at-a-distance: cause and effect are in different parts of the code, and to predict any line you must know the entire history of who-touched-what.

Now the same shape with an immutable List and a case class. The helper cannot reach back and edit the caller's data, because there is no operation that edits a List at all.

case class Cart(items: List[String])

def previewWithFreebie(cart: Cart): Int =
  cart.copy(items = cart.items :+ "free-gift").items.size   // builds a NEW Cart

val myCart    = Cart(List("book", "pen"))
val alsoMyCart = myCart                 // a second name — but for an unchangeable value

val n = previewWithFreebie(myCart)      // n == 3, computed from a fresh Cart
println(myCart.items)       // List(book, pen)  -- untouched
println(alsoMyCart.items)   // List(book, pen)  -- untouched

The helper still computes 3, but it does so by building a brand-new Cart and asking its size. The original myCart is exactly what it always was. Aliasing is now harmless: alsoMyCart is a second name for a value that nobody can change, so it does not matter how many names point at it. The bug is not "less likely" — it is unrepresentable, because the language offers no way to mutate the shared thing.

Worked example — trace the difference
Mutable world: myCart → buffer #A [book, pen]; alsoMyCart → buffer #A; helper does #A += "free-gift" → buffer #A is now [book, pen, free-gift]; both names see #A, so both read three items. One object, three observers, surprise. Immutable world: myCart → value #A [book, pen]; alsoMyCart → value #A; helper builds value #B [book, pen, free-gift], reads #B.size == 3, and discards #B; #A was never touched, so both names still read two items. The mutable version forces you to track time; the immutable version lets you read each line as a standalone fact.

2 · Value versus identity

Two questions get conflated when data is mutable, and pulling them apart is the heart of this lesson:

value — what it is
The contents. List(1,2,3) is the sequence 1,2,3. Two separately built List(1,2,3)s have the same value and are ==-equal; either can stand in for the other anywhere.
identity — which object
The particular object in memory ("the one at address #A"), tested in Scala with eq. Identity is about which box, independent of what is inside it right now.

Identity only earns its keep when things mutate. If object #A can change, then "is this the same object as the one I stored?" is a real, load-bearing question — because if it is, a change here will be seen there. But when values are immutable, identity becomes irrelevant: two values that are equal now will be equal forever, so there is never a reason to care whether you hold "the same object" or "an equal copy." They are interchangeable in every situation.

val a = List(1, 2, 3)
val b = List(1, 2, 3)
a == b   // true  -- same VALUE
a eq b   // (likely) false -- different objects, i.e. different IDENTITY
// Because neither can ever change, the eq answer never matters to your logic.

This is why immutability quietly makes three things safe at once. Equality behaves: a value's contents define it, and they never drift, so == is a stable fact rather than a snapshot. Caching / memoization is safe: if you stored a result keyed by an immutable input, that key cannot mutate underneath you and corrupt the cache. Sharing is safe: handing the same value to ten callers can never let one caller's later "change" leak to the others, because there is no later change. With mutable data, every one of these requires defensive copies and careful reasoning about identity; immutability deletes the question.

3 · Copy-on-write, and history for free

"Never changes in place" does not mean "never updates." It means an update is a function: take the old value, return a new value that differs in the intended way, and leave the old one standing. This is copy-on-write. You already met the operators in Lesson 02; here is what they return.

case class Account(owner: String, balance: Int)

val before  = Account("ada", 100)
val after   = before.copy(balance = 150)   // a NEW Account; `before` is unchanged

before.balance   // 100   -- still valid, still true
after.balance    // 150

val xs  = List(2, 3)
val ys  = 1 :: xs        // prepend -> List(1, 2, 3); xs is still List(2, 3)
val zs  = xs :+ 4        // append  -> List(2, 3, 4); xs is still List(2, 3)
xs       // List(2, 3)   -- every "update" left the original intact

Notice the payoff hiding in plain sight: after these updates you still hold before and xs — the prior states — and they are still completely valid values. That is a history, and you got it for free. Undo is "use the old reference you still have." Time-travel debugging, audit logs, and "compare the state before and after" all become trivial, because the old value was never destroyed to make the new one. With mutable data you would have had to deliberately snapshot a copy beforehand and hope you copied deeply enough; with immutable data the old value is simply still there.

Why this is also safe to share
Because before can never change, you can hand it to any number of other functions, threads, or caches with zero risk that something downstream will alter it and surprise the others. "Build a new value to update" and "share freely without defensive copies" are the same property seen from two sides.

4 · Why this matters for concurrency (preview of Lesson 13)

Most concurrency bugs are aliasing bugs with a stopwatch: two threads hold the same mutable object and write to it without coordinating, so the final state depends on who-won-the-race — a data race. The standard fix is locking: make threads take turns. Locks are slow, easy to get wrong (deadlocks, forgotten locks), and they re-introduce ordering-dependence — the very thing we are trying to escape.

Immutability sidesteps the whole problem. If a value can never change, then it does not matter how many threads read it at once, in what order — there is nothing to coordinate, because there is no write. A shared immutable value needs no lock. This is the deepest reason the functional core from Lesson 00 is "safe to run in parallel": its data cannot be raced on. Lesson 13 develops this into safe streams and Future; for now, keep the slogan: no shared mutation, no data races, no locks.

5 · The cost worry, stated honestly

The fair objection arrives immediately: if every update builds a new value, am I not copying the entire structure on every change? Append to a million-element list a thousand times and surely that is a billion copies. If immutable updates really deep-copied everything, they would indeed be too slow to use.

They do not. The intuition to hold now: you only build the part that differs, and you share the part that does not. Prepending 1 :: xs does not copy xs — the new list is a single new node whose tail points at the existing xs. Both the old and new list share that same tail, and sharing is safe precisely because (§3) nobody can mutate it. So an "update" is often O(1) or O(log n) new nodes plus a pointer to the untouched rest, not a full copy.

A promise, paid off in Lesson 12
The mechanism that makes this real — persistent data structures and structural sharing (cons-lists, and trees/tries behind Vector and Map) — is the subject of Lesson 12. For now, accept the intuition: immutable updates reuse the unchanged parts, so they are cheap, not wasteful. Lesson 12 closes this exact loop with the data structures that earn the promise.

Common mistakes / failure modes

A val holding a mutable object is NOT immutable
val fixes the reference, not the contents. val buf = ListBuffer(1,2) forbids buf = somethingElse, but buf += 3 still mutates the buffer in place — the aliasing bug of §1 is fully back. Immutability is a property of the data type, not of the keyword pointing at it.
Shallow vs deep immutability
An immutable case class whose field is a mutable thing is only shallowly immutable: case class Box(items: ListBuffer[Int]) — you cannot reassign the field, but you can still box.items += 9. Make it immutable all the way down: case class Box(items: List[Int]).
"Updating" and dropping the result
list :+ x and acc.copy(...) return the new value — they do not change the receiver. Writing xs :+ 4 on its own line and then reading xs is a no-op bug; you must bind the result: val xs2 = xs :+ 4.
Confusing equality with identity
Reaching for eq (identity) when you mean == (value). With immutable data you almost always want ==; identity is rarely the question, because equal values are interchangeable forever (§2).

Checkpoint exercise

Try it
You are handed this mutable account, shared by two methods:
class Account(var balance: Int) {
  def deposit(n: Int): Unit  = { balance += n }   // mutates in place
  def withdraw(n: Int): Unit = { balance -= n }   // mutates in place
}
(a) Write a two-line aliasing bug: bind val acct = new Account(100) and val same = acct, call same.withdraw(40), and show that acct.balance changed even though you "only touched" same. (b) Now redesign it immutably: a case class Account(balance: Int) whose deposit/withdraw return a new Account via .copy instead of mutating. (c) Repeat the aliasing setup with the new design and argue why the bug is now impossible — what is same.balance after same.withdraw(40) returns a new account, and is the original acct affected? (d) State the free bonus: name the value that still holds the pre-withdrawal state.

Where this points next

Immutable data is the first pillar, but it is only half the story. You can have data that never changes and still write a function that reads the clock, logs to disk, or throws — and such a function still means different things at different times. Lesson 04 supplies the other half: purity and referential transparency. It defines precisely what it means for a function to depend only on its inputs and do nothing else, sharpens the substitution model from Lesson 01 into a proof tool, and shows that purity + immutability together are what make code testable and parallelizable. Where this lesson removed the bug of shared mutable state, the next removes the bug of hidden effects.

Takeaway
A mutable object reachable by two names is an aliasing trap: a change made through one name appears through the others, far from the cause, so every line's meaning depends on the program's whole history. Immutability removes this entire bug class by making in-place change unrepresentable — you "update" by building a new value (.copy, ::, :+) and the old one survives untouched, which simultaneously gives you free history/undo, safe sharing and caching, and a clean separation of value (what it is, compared with ==) from identity (which object), the latter ceasing to matter once nothing can change. The same property makes immutable values shareable across threads with no locks (Lesson 13). The natural worry — "doesn't building a new value copy everything?" — is answered by structural sharing: you build only the changed part and reuse the rest, with the mechanism delivered in Lesson 12. Lesson 04 adds the second pillar, purity, to finish controlling effects as this lesson controlled state.

Interview prompts