jonnifyfsharp

Dive C1.02.2

Shadowing, not mutation

Rebinding a name makes a new binding; the old value is untouched.

If a let binding cannot be reassigned, what is happening when F# code writes let x = x + 1? Not reassignment. It is a second binding of the name x, computed from the first, that shadows the earlier one. The earlier value still exists, unchanged; the new name takes priority for code that comes after. Shadowing and mutation look the same on the page and are not the same underneath — and a closure that captured the first binding proves it.

C1.02.2 · part 1

Rebinding a name is a fresh binding

Writing let x = … a second time does not change the first x. It introduces a new binding of the same name. The right-hand side can read the old binding — it is still in scope when the new one is being defined — so let x = x + 1 means: take the current x, add one, and bind the result to a new x.

let x = 1
let x = x + 1      // a new binding x = 2, computed from the old x (1)
printfn "%d" x     // 2

After these lines, the name x refers to 2. The original binding to 1 was not overwritten; it was used to compute the new one and then shadowed. This reads like assignment and is not — the difference shows the moment a closure enters.

shadowing
a later let binds the same name afresh, hiding the earlier binding.
old binding
still exists; the right-hand side may read it while computing the new value.
not mutation
no value changed in place; a second, distinct binding was created.
C1.02.2 · part 2

An inner scope shadows only within itself

A binding inside a function or block shadows the outer name only for the duration of that scope. Step out, and the outer binding is back, exactly as it was.

let x = 10
let f () =
    let x = 20     // shadows within f only
    x
f ()               // 20
x                  // still 10

Inside f, the name x is 20; the inner binding wins there. Outside f, the outer binding is untouched, so x is 10 still. Shadowing is scoped: it never reaches back out to change a binding in an enclosing scope.

The decisive case: a closure captures the binding in scope

This is where shadowing and mutation part ways for good. A function captures the binding that was in scope when it was defined. Shadow the name afterwards and the function is unmoved — it still refers to the binding it captured, not the newer one.

Step through the four lines below. Watch the binding stack on the left grow, and watch which x the function getX is tied to.

getX captures the first x · a later shadow does not change it

x = 1 first binding x = 2 shadows for later code getX () = x closure captures
  1. let x = 1 binds x to 1
  2. let getX () = x captures the first x (1)
  3. let x = 2 a new binding shadows for later code
  4. getX () returns 1 — the captured x, not the shadow
step 1 of 4 · let x = 1 · current x is 1; no closure yet

A closure is tied to the binding, not the name. Shadow the name a hundred times after; the function still returns the value it captured. Mutation would have changed it — shadowing cannot.

looks like assignment

let x = x + 1 reads like overwriting x in place, the way an imperative variable would.

is a new binding

The earlier binding survives; a closure that captured it returns 1, proving nothing was mutated.

C1.02.2 · part 3

Why this matters, and what comes next

Shadowing is useful: it lets a name be refined through a pipeline of steps without inventing x1, x2, x3, while keeping every earlier binding intact for anything that already captured it. Because no value changes in place, the equational reasoning from C1.02.1 still holds — each x refers to a single, fixed value within its scope.

It also has an edge: a shadow can hide a name you meant to use, and the types need not match across the shadow, so a typo can compile. That is the cost of the convenience, not a reason to avoid it. When a value genuinely must change in place — a loop counter, a running total — the tool is mutable, and that is C1.02.3.

References

Sources

Related in this course