Introducing Assignment: The Cost of Mutation
What This Concept Is
Assignment is the move from "this name is bound to a value" to "this name currently holds a value, and can be reassigned." In Scheme the operator is set!; in Python, = in a function body; in C, x = 7.
SICP §3.1 takes this move very seriously. Once set! is in the language:
- a variable is no longer equivalent to the expression it was bound to (substitution breaks -- see Concept 07)
- two expressions that look identical can produce different values at different times
- the identity of an object becomes separate from its value: two counters with
n = 0are not the same counter - referential transparency is lost: you can no longer replace a call by its result without changing behavior
These are not minor adjustments. Chapter 3 is essentially the chapter on "what you give up when you accept mutation, and what you get in return."
Why It Matters Here
This concept is the keystone of the last cluster:
- it motivates Concept 14 (streams) as a pure alternative that still models time
- it motivates Concept 15 (concurrency) as the place mutation becomes genuinely dangerous
- it explains why object-oriented programming is fundamentally a story about mutable local state (SICP §3.1 is the bank-account OO tutorial, in disguise)
- it closes the loop with Concept 07: the environment model is what you need as soon as you have mutation
In a professional setting, this is the concept that lets you reason about thread safety, cache consistency, database transactions, and undo/redo. Every mutation story ultimately traces back here.
Concrete Example
Without assignment: a "balance" function that returns a new balance each call.
(define (deposit balance amount) (+ balance amount))
(deposit (deposit 100 50) 25) ; => 175
Pure, substitutable, trivially thread-safe, no notion of "account."
With assignment: a stateful account (SICP §3.1).
(define (make-account balance)
(define (deposit amount)
(set! balance (+ balance amount))
balance)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount)) balance)
"insufficient funds"))
(define (dispatch m)
(cond ((eq? m 'deposit) deposit)
((eq? m 'withdraw) withdraw)
(else (error "unknown message"))))
dispatch)
(define acc (make-account 100))
((acc 'deposit) 50) ; => 150
((acc 'deposit) 50) ; => 200
acc is an object. It has identity (two calls to make-account make two accounts). Its balance is mutable. Calls are not substitutable: (acc 'deposit) 50 returns different values each time.
The Python analogue falls out in a page:
def make_account(balance):
def deposit(amount):
nonlocal balance
balance += amount
return balance
return deposit
The hidden cost: any code that reads balance must reason about when the read happens relative to writes. In a single thread that is tolerable; across threads it is a source of correctness disasters (Concept 15).
Common Confusion / Misconception
- "Assignment is just convenience." It is a change to the semantics of the language. Substitution no longer works; reasoning becomes temporal. The SICP authors call
set!a "dreadful thing" on purpose: it is necessary, but it is not free. - "Immutable data is always slower." Often the opposite in real programs -- fewer defensive copies, easier caching, better concurrency. Functional data structures are remarkably efficient for many workloads.
- "Local
set!is safe." Local mutation inside a procedure that does not escape is generally fine. What matters is whether mutable state is visible outside, and whether multiple callers can race on it. - Confusing shadowing with mutation. In
let,let*, Python function arguments: a new binding shadows an outer one without mutating it.x = x + 1in Scheme is(let ((x (+ x 1))) ...)-- new frame, notset!.
How To Use It
Before you reach for mutation, answer three questions:
- Is there a pure alternative that is readable? Often yes -- a fold, a threaded accumulator, an immutable update.
- What is the scope of the mutation? A local variable in one procedure is cheap. A module-level variable is expensive. A mutable shared object across threads is dangerous.
- What do you lose? Substitution, easy unit tests with fresh state, easy parallelism. List what you give up before committing.
When reviewing code, flag any mutation on:
- module-level variables
- arguments (in Python, accidentally mutating a list parameter)
- fields of objects shared between threads
- caches that do not have an invalidation story
Check Yourself
- Why does
set!break referential transparency? - Explain, in one sentence, why two calls to
make-account 100produce two different accounts even though they "look the same." - Give one example where introducing mutation is the right call and one where it is a smell.
Mini Drill or Application
Take this stateful counter and produce three variants:
- (a) identical functionality in Scheme using
set! - (b) identical functionality in Scheme without
set!, by threading state through arguments - (c) identical functionality in Python using
classwith a singletick()method
Compare the three on readability, ease of unit testing, and thread-safety. Which would you default to for (i) a single-threaded CLI, (ii) a multi-threaded web server?