jonnifyfsharp

Dive C1.02.1

let bindings & immutability

A name binds a value, and the binding holds — reassigning it does not compile.

The most-used keyword in F# is let. It binds a name to a value, and that binding is immutable by default: the name cannot be pointed at a different value later. This is the opposite of the variable in most imperative languages, where assignment rebinds in place. The payoff is that a name's meaning is fixed for the whole scope, so reading code is reading the values once. This dive covers the binding form, local and nested bindings under the offside rule, and the exact compiler error that greets an attempt to reassign.

C1.02.1 · part 1

Binding a name to a value

A let binding has one shape: let name = value. The right side is evaluated once, and the name stands for the result from that point on. F# infers the type, so the type annotation is optional; the comment after each line below shows what the F# Interactive prints back.

Bindings compose. A later binding can be defined in terms of an earlier one, and the value carries the inferred type with it — here float, because the literals are floats.

let pi = 3.14159
let r = 2.0
let area = pi * r * r   // val area : float = 12.56636

Each name on the left is now welded to its value. pi is 3.14159 for the rest of the scope; area is the product computed once. Nothing reassigns later, because in F# a plain let binding cannot be reassigned at all.

let name = e
evaluate e once and bind its value to name.
immutable
the default — the name cannot later be pointed at a different value.
type inference
F# works out the type; the annotation is optional.
val name : T = v
what F# Interactive echoes — the name, its inferred type, and the value.
C1.02.1 · part 2

Local bindings and the offside rule

A binding can be local to an expression. Indent further-right bindings under a let, and they exist only inside it; the last expression is the value of the whole block. F# uses indentation to mark scope — the offside rule — so no braces or in keyword are needed.

let result =
    let a = 2
    let b = 3
    a + b               // val result : int = 5

Here a and b are private to result. Outside the block they are not in scope. Each is an ordinary immutable binding; the block's last line, a + b, is its result. Local bindings keep the names a computation needs close to where they are used, without leaking them.

The offside rule means alignment matters. The two inner let lines and the final expression sit at the same indent, deeper than the outer let result =; that shared indent is what groups them into one block.

Immutable by default: the FS0027 error

What happens when code does try to reassign a let binding? The compiler rejects it. F#'s assignment operator is <-, and applying it to a plain binding produces a specific, named error. Walk the three states below: a binding, the rejected reassignment with the exact compiler message, and the form that does compile.

A binding, a rejected reassignment, and the fix · select a state

x 5 name — locked to its value name value
let x = 5      // val x : int = 5 — an immutable binding
let x = 5 · binds x to 5; the name is fixed to that value for the rest of the scope

The default is not advice; it is enforced. A plain binding cannot be reassigned, and the compiler says so by name — FS0027 — and points at the keyword that would change the default.

the rule

A binding fixes a name to a value. To get a different value you write a different binding.

F# enforces it

x <- 6 on a plain let is rejected at compile time with error FS0027.

C1.02.1 · part 3

Why immutable by default

The default is a deliberate trade. Three properties follow from a name keeping its value.

Equational reasoning. When area is bound once, every later area means the same thing; a reader can substitute the value and the program holds. No action at a distance. A function cannot reach back and change a binding a caller relies on, so a value passed in is the value used. Safe to share. A value that never changes is safe to read from many threads at once, with no lock and no copy — the concurrency story C3 builds on.

None of this forbids change; it relocates it. When a value genuinely must change in place, F# offers mutable, covered in C1.02.3. And rebinding a name with a fresh let — which looks like assignment but is not — is shadowing, the subject of C1.02.2.

References

Sources

Related in this course