Pure functions and immutability
Memoising an impure function silently gave 5 5 5 where the program meant 5 10 15. And an immutable object seen half-updated: never.
A pure function returns the same output for the same input and changes nothing outside itself. An immutable object cannot change after it is built. These sound like style preferences. They are guarantees, and the guarantees are what let you cache, parallelise and share without thinking — which is the only reason the functional style is worth the effort.
What purity buys, shown by what breaking it costs
Memoisation — remembering a result so you do not compute it twice — is only correct for a pure function. Here it is applied to one of each:
Function<Integer, Integer> square = x -> { calls++; return x * x; };
Function<Integer, Integer> addToRunningTotal = x -> { total += x; return total; };### square(4) three times, memoised: result 16, computed 1 time(s)
### addToRunningTotal(5) three times, memoised: 5 5 5
### addToRunningTotal(5) three times, NOT memoised: 5 10 15square memoised perfectly: same answer, computed once. addToRunningTotal memoised silently changed what the program does — it returned 5 5 5 where the unmemoised version returns 5 10 15. No error. The cache simply assumed the function was pure, and it was not.
That is the practical definition worth carrying. A function is pure when you can replace a call with its result and nothing changes. Everything the functional style offers is a consequence of being allowed to do that:
- Caching is safe only if a call can be replaced by a remembered result.
- Parallelism is safe only if calls do not interfere through shared state — the paradigms lesson's parallel stream that gave three different wrong sums was an impure lambda.
- Testing needs no setup: input in, output checked, nothing to mock.
- Reasoning is local: you understand a pure function by reading it, with no need to know what else ran first.
The side effects that hide
A side effect is anything a function does besides returning a value. The obvious ones are easy to spot; these are not:
- Reading the clock.
LocalDate.now()inside a function makes it return different answers on different days — the flaky tests lesson's first cause. - Reading a mutable field that something else changes. The function does not write it, and it is still impure.
- Throwing on some inputs, in some views of purity. Returning an
Optionalor a result type keeps the failure in the return value. - Logging. Genuinely a side effect, and almost always one worth tolerating. Purity is a spectrum, and logging is where most people sensibly stop.
- Mutating an argument.
sort(list)in place is a side effect on the caller's data.
The move that keeps most code pure without heroics is push the effects to the edges: read the clock, the database and the request once at the boundary, pass the values in, and keep the logic in the middle pure. Then the effectful part is thin and the part with the rules is trivially testable.
// the clock is an input, not something the rule reaches for
boolean isOverdue(Invoice invoice, LocalDate today) {
return invoice.dueDate().isBefore(today);
}Immutability as a concurrency strategy
Here is the concurrency argument, measured. One thread writes a range with lo and hi always equal; another checks whether it ever sees them unequal:
// mutable: two separate writes, observable in between
m.lo = i; m.hi = i;
// immutable: a whole new object, swapped in by one reference write
immutable = new Range(i, i);### mutable object, torn reads seen : 4090
### immutable object, torn reads seen : 0Four thousand and ninety torn reads on the mutable one — moments when the reader saw lo updated and hi not yet. None on the immutable one. No locks anywhere in either.
The reason is structural. A mutable object can be observed between its updates. An immutable object is built completely before anyone can see it, and replacing it is a single reference write — there is no "between" to observe. Nothing to lock, because nothing changes.
That is why the concurrency course reaches for immutable snapshots, why String being immutable makes it safe to share, and why CopyOnWriteArrayList exists: the cheapest lock is the one you do not need.
Making it cheap in Java
Java does not make immutability the default, so it takes a few deliberate habits:
- Records for data: every field
final, no setters,equalsandhashCodegenerated. List.of,Map.of,List.copyOffor collections — unmodifiable, so a returned list cannot be changed by the caller.- Defensive copies at the boundary. A constructor taking a
ListshouldList.copyOfit, or the caller keeps a reference and can mutate "your" immutable object. - "Withers" instead of setters:
order.withStatus(SHIPPED)returns a new object rather than changing this one.
The cost is honest and worth saying: a new object per change is more allocation. For almost all application code the garbage collector makes that invisible, and the concurrency and correctness it buys is far worth it. In a hot loop allocating millions of objects, measure first.