jonnifyfsharp

Dive C2.02.2

Immutable by default

let is not var; a record has no setters; a changed value is a copy.

In C#, a var can be reassigned and a class field can be mutated in place; sharing a reference and then changing it is how a value drifts in one place because it was edited in another. F# starts from the opposite default. let binds a value that does not change — rebinding the same name in the same scope is an error, not an assignment. A record has no setters; to produce a record that differs in one field you make a new one with copy-and-update, { r with field = v }, and the original stays exactly as it was. A C# developer has met this idea already: C# records also have with. F# gives every record that, and makes immutability the default rather than the opt-in. This dive covers let, copy-and-update, and one distinction that trips a C# reader: shadowing is a new binding, not mutation of an old one.

C2.02.2 · part 1

let binds an immutable value

The motivation for the default is concrete. In C#, two names can refer to the same mutable object, and a change through one is visible through the other — aliasing. The F# default removes the surprise: a bound value does not change, so there is nothing to alias into a bug.

C# — a shared mutable, edited in one place

var a = new List<int> { 1, 2, 3 };
var b = a;              // same list, not a copy
b.Add(4);
// a.Count == 4 — a changed through b

F# — an immutable value, summed

let total = [1..5] |> List.sum
// val total : int = 15

// total is bound once and never changes;
// nothing else can edit it from a distance
let
binds a value that does not change; it is not a reassignable var.
aliasing
the C# hazard of two names for one mutable object; an immutable value cannot be aliased into a bug.
no setters
a record's fields are read-only; a changed record is a new value.

An immutable binding cannot drift. The value a name held when it was bound is the value it holds everywhere, for as long as the name is in scope.

C2.02.2 · part 2

copy-and-update: { r with field = v }

To produce a record that differs in one field, F# makes a new record from an old one with copy-and-update. The bridge a C# developer already knows: C# records carry the same with expression, and it behaves the same way — a new value, the source unchanged. The two languages are shown side by side; the only difference is that in F# every record has this, as the default.

C# — record with-expression

record Point(double X, double Y);
var p  = new Point(1, 2);
var p2 = p with { Y = 9 };
// p2 = Point { X = 1, Y = 9 }
// p  = Point { X = 1, Y = 2 }  (unchanged)

F# — copy-and-update

type Point = { X: float; Y: float }
let p  = { X = 1.0; Y = 2.0 }     // val p : Point = { X = 1.0; Y = 2.0 }
let p2 = { p with Y = 9.0 }       // val p2 : Point = { X = 1.0; Y = 9.0 }
p                                  // val it : Point = { X = 1.0; Y = 2.0 }  (unchanged)

Copy-and-update is the F# answer to a setter: it does not change p, it produces p2. The C# developer met the syntax on records; in F# it is how every record changes.

Change one field of p

The record p = { X = 1.0; Y = 2.0 } is fixed. Pick a field to update. The diagram shows the new record p2 beside the original: the changed field is highlighted on p2, every other field is copied unchanged, and p stays exactly as it was. The readout shows the precise FSI values of both.

{ p with … } · the original is never touched

p — the original p2 = { p with … } X = 1.0 Y = 2.0 copy + update X = 1.0 copied Y = 9.0 changed
p2 = { X = 1.0; Y = 9.0 } · p = { X = 1.0; Y = 2.0 } (unchanged)
Without JavaScript: the three updates, computed by hand · p = { X = 1.0; Y = 2.0 }
updatep2p (after)
{ p with Y = 9.0 }{ X = 1.0; Y = 9.0 }{ X = 1.0; Y = 2.0 }
{ p with X = 8.0 }{ X = 8.0; Y = 2.0 }{ X = 1.0; Y = 2.0 }
{ p with X = 8.0; Y = 9.0 }{ X = 8.0; Y = 9.0 }{ X = 1.0; Y = 2.0 }

Whatever field changes, the new record carries the rest by copy and the original is untouched. That is the shape of every “modification” of immutable data: a new value, the source intact.

C2.02.2 · part 3

Shadowing is a new binding, not mutation

Inside a scope, a second let of the same name is allowed — this is shadowing. It looks like reassignment, and a C# reader can mistake it for one, but it is not: the new let introduces a different value that hides the earlier one from that point on. The old value is unchanged; any reference taken before the shadow still sees it.

let demo () =
    let x = 5             // first binding
    let x = x + 1         // shadowing: a NEW x = 6, the old one hidden
    x
// val demo : unit -> int

demo ()                           // val it : int = 6
Shadowing needs a nested scope — here, a function body. At the top level of a module two let x bindings collide: F# reports error FS0037: Duplicate definition of value 'x'. (An interactive FSI session is the exception: each entry is its own scope, so successive let x entries shadow.)

The C# habit

Reassign a var; mutate a field; share a reference and edit it in place.

F# today

let is immutable; a change is { r with … }; a re-named value shadows, it does not mutate.

Where this leads

Immutability is the F# default, not a ban. There are times — a tight accumulation loop, a Dictionary at an interop boundary — where in-place change is the right tool. The next dive shows how F# admits exactly that, while keeping it from leaking: mutation is contained.

References

Sources

Related in this course