jonnifyfsharp

Dive C1.02.3

Mutability when you need it

Opt in with mutable; write with <-. Deliberate, and kept local.

Immutable is the default, not a prohibition. When a value genuinely must change in place — a loop counter, a running total, a buffer handed to a .NET API — F# offers let mutable, and the <- operator writes a new value to that binding. This dive shows the mutable form, the reference-cell alternative, the sharp line between mutation and shadowing, and the guidance: reach for it deliberately and keep it local — the same answer is often one immutable call away.

C1.02.3 · part 1

let mutable and the <- operator

Adding the mutable keyword to a binding makes it assignable. From then on, name <- newValue stores a new value in the same binding. The classic use is an accumulator updated across a loop.

let mutable total = 0
for i in 1..5 do
    total <- total + i
total              // val it : int = 15

Here total starts at 0 and the loop body assigns a new value on each pass: 0, 1, 3, 6, 10, 15. The same binding is updated five times in place. After the loop, total is 15 — the sum of 1 through 5.

let mutable n = v
create an assignable binding initialised to v.
n <- e
the assignment operator — store the value of e in the binding n.
same binding
mutation changes the value the existing name holds; no new binding is made.
C1.02.3 · part 2

Reference cells: ref, .Value, <-

A reference cell is the other way to hold a value that changes. ref v creates a cell holding v; you read it with .Value and write it by assigning to .Value. The cell itself is an ordinary immutable let binding — what changes is the contents it points at.

let counter = ref 0
counter.Value <- counter.Value + 1
counter.Value      // 1

The binding counter never changes — it is the same cell throughout. counter.Value reads the contents, counter.Value <- … writes them. A reference cell is a value you can pass around and mutate through, which is what distinguishes it from a plain mutable binding.

Both forms use <- to write. With mutable you assign to the name; with a ref cell you assign to its .Value. The .Value property is the current idiom; older F# code used the ! and := operators for the same reads and writes.

Watch the cell change — then the immutable equivalent

Step the loop one iteration at a time. The cell on the left is the single total binding; each step assigns it a new value with <-. To the right is the same result with no mutation at all — List.sum over the list [1..5].

let mutable total = 0 · total <- total + i over 1..5

total = 0 one binding, updated in place for i in 1..5 i = — total <- 0 + i
  1. let mutable total = 0 initial value, before the loop
  2. i = 1 · total <- 0 + 1 = 1 first iteration
  3. i = 2 · total <- 1 + 2 = 3 second iteration
  4. i = 3 · total <- 3 + 3 = 6 third iteration
  5. i = 4 · total <- 6 + 4 = 10 fourth iteration
  6. i = 5 · total <- 10 + 5 = 15 fifth iteration — done
before the loop · total = 0 · the immutable form [1..5] |> List.sum gives the same 15 with no mutation
// the same result, no mutation
[1..5] |> List.sum   // val it : int = 15

Mutation is a tool, not a default. The loop reaches 15 by updating one binding five times; [1..5] |> List.sum reaches the same 15 by changing nothing. Prefer the second; use the first where it earns its keep.

mutation

total <- total + i changes the value the same binding holds, again and again.

shadowing

let x = x + 1 makes a new binding and leaves the old value intact — a different thing entirely.

C1.02.3 · part 3

<- is assignment; = is binding

Two operators that read alike and do not coincide. = binds a name or tests equality; <- assigns to a mutable target. Writing let x = 5 binds; writing x <- 6 assigns — and assignment only compiles when the target was declared mutable or is a reference cell. Confusing the two is a common first-week error, and the compiler's FS0027 (from C1.02.1) is exactly the reminder that the target was not mutable.

The guidance is short. Keep immutability the default. Reach for mutable deliberately — hot loops, local accumulation, .NET interop — and keep it local, so the mutation cannot be observed from outside. Where a standard-library function expresses the same computation, List.sum, List.fold, and the rest say it without any mutation at all. That choice is the whole of C1.02: a binding holds; a shadow replaces a name; mutation, used sparingly, changes a value in place.

References

Sources

Related in this course