jonnifyfsharp

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.

C2.02.3 · part 1

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 let may 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

xs (never changes) 1 2 3 4 5 inside sumLoop — the mutable local mutable t 0 t <- t + x signature: int seq -> int
step 0 · t = 0 · not started · returns int seq -> int
Without JavaScript: every step of sumLoop [1; 2; 3; 4; 5], computed by hand
stepx takent <- t + xt after
0 · start—t = 00
110 + 11
221 + 23
333 + 36
446 + 410
5 · done510 + 515

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.

C2.02.3 · part 2

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
The same containment rule applies. A 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.
C2.02.3 · part 3

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

Related in this course