Dive C2.02.3
Mutation, contained
F# does not ban mutation — it keeps it from leaking.
Immutability is the default, not a prohibition. F# admits in-place change for the cases where it
is the right tool — a tight accumulation loop, a buffer being filled, a Dictionary
at the boundary with C# — but it contains that change. A let mutable
binding can be reassigned with the <- operator, and a ResizeArray
(the F# name for System.Collections.Generic.List<_>) or a
Dictionary can be filled element by element. The discipline is where the mutable lives:
kept local to a function, it never reaches a caller, so the function still presents a pure
signature — the same int -> int the rest of the program reads. This dive covers
the local let mutable, the mutable collections for hot loops and interop, and the line
between a contained mutable and a leaked one.
let mutable and the <- operator
A binding declared let mutable can be changed with <-
— the assignment operator, distinct from =, which is equality and binding. On
its own, the two lines below are exactly what a C# reader expects: a variable, then a reassignment.
let mutable x = 5 x <- 6 x // val it : int = 6
The value of the construct is the discipline around it. A let mutable is most often
a loop accumulator: a counter or running total that a for loop updates, declared
inside the function and never returned. The function below sums a sequence by mutating a local
t — and its signature is a plain
int seq -> int. To a caller it is a function from a sequence to a number; the mutable
stays invisible.
let sumLoop xs = let mutable t = 0 for x in xs do t <- t + x t // val sumLoop : int seq -> int sumLoop [1..5] // val it : int = 15
- let mutable
- a binding that may be reassigned; ordinary
letmay not. - <-
- the assignment operator;
=is equality and binding, never assignment. - contained
- the mutable is declared inside the function and never escapes it; the signature stays pure.
The mutable t lives and dies inside sumLoop. The
outside sees only int seq -> int — a pure function, however it is implemented.
Step sumLoop [1..5]
The list [1; 2; 3; 4; 5] is fixed. Step the loop. Each step takes the next element
and mutates the local accumulator t <- t + x; the consumed cells turn green and
t rises inside the box. Through all of it, the function's signature stays
int seq -> int and the list itself is never changed — the mutation is entirely
inside. The final t is the returned value, 15.
t mutates inside · the signature stays pure
| step | x taken | t <- t + x | t after |
|---|---|---|---|
| 0 · start | — | t = 0 | 0 |
| 1 | 1 | 0 + 1 | 1 |
| 2 | 2 | 1 + 2 | 3 |
| 3 | 3 | 3 + 3 | 6 |
| 4 | 4 | 6 + 4 | 10 |
| 5 · done | 5 | 10 + 5 | 15 |
The total climbs inside the box; the list outside it does not move. When the loop ends,
sumLoop returns 15 — and a caller never knew a mutable
was involved.
ResizeArray and Dictionary, for hot loops and interop
F# also has the .NET mutable collections, under F#-flavoured names. ResizeArray<_>
is System.Collections.Generic.List<_> — a growable buffer with an in-place
Add. It earns its place in two situations: a hot loop where rebuilding an immutable
list each iteration would cost too much, and the boundary with C# code that hands back or expects a
List<T> or a Dictionary<K,V>.
C# — the same List, the same Add
var buf = new List<int>(); buf.Add(1); buf.Add(2); // buf.Count == 2
F# — ResizeArray, filled in place
let buf = System.Collections.Generic.List<int>() buf.Add 1 buf.Add 2 buf.Count // val it : int = 2
ResizeArray built and filled inside a
function, then returned as an immutable list or array, keeps
the mutation local. A ResizeArray handed out and mutated by several callers is the C#
hazard the rest of this module avoids — use it at the boundary, then convert.The line: contained, not leaked
The whole point is the boundary. A mutable that lives inside a function and is gone when the function
returns is contained: callers cannot observe it, so the function is pure from the outside,
and the immutability the module built still holds for the program. A mutable shared across calls — a
module-level let mutable, or a ResizeArray passed around and
edited from several places — is leaked, and brings back exactly the aliasing the
earlier dives removed.
Contained
A let mutable local to a function; the signature stays int -> int; callers see purity.
Leaked
A mutable shared across calls; the aliasing the module removed returns — the case to avoid.
The module, complete
The two C# defaults this module set out to invert are inverted. Everything is an expression returning a
value; values do not change; and where in-place change is the right tool, it stays inside a function and the
outside still reads pure. The chapter's next module follows the other day-one surprise for a C# developer:
null and exceptions give way to Option and
Result — failure modelled as a value rather than a thrown event.
References
Sources
- Microsoft — Mutable variables and
<-—let mutableand the assignment operator. - Microsoft — Loops:
for…in— the loop the accumulator runs over. - FSharp.Core API reference —
ResizeArrayand the .NET collection types F# reaches at the interop boundary.
Related in this course
- C2.02.2 — Immutable by default — the default this dive complements.
- C2.02 — Immutability & expressions — the module hub and its three dives.
- C1.02 — let bindings, immutability & shadowing — the same ideas from the language side.
- Elixir — Functional foundations — immutability on the BEAM, where there is no mutable binding at all.