F# from the ground up

let bindings that never change

A name in F# is a definition rather than a slot: there is nothing to assign to, so a value you read at line three still means the same thing at line thirty.

Part 2 of 12

Beginner15 min readBack to Courses

A binding is a definition

let price = 12 does not put 12 into a box called price. It defines price to mean 12 for everything below it, and there is no syntax for changing it afterwards: no re-assignment, no ++, no +=. When you want a different value you write a new line with a new name.

This sounds like a restriction and behaves like a guarantee. A name you read on line three says the same thing on line thirty, so a piece of arithmetic can be checked where it is written, without hunting for the line that changed one of its inputs.

A new name, not a new value

When the price goes up, the program gets a second binding worked out from the first: let cheaper = price - 5. Both names exist; neither moves. total was computed from the price as it was, and it keeps that answer.

Inside a function a name may be defined again and the nearest definition wins below it. That is shadowing, and it is how a value is refined in steps without inventing text1 and text2. At the top level of a script, though, a name may be defined only once: a second let price is rejected with FS0037: Duplicate definition of value, which is the rule that keeps a file a list of definitions rather than a sequence of edits.

Defining values, then refining one

let price = 12
let count = 3
let total = price * count

let label n =
    let text = "books"
    let text = sprintf "%d %s" n text
    text

Four bindings and a shadowed name

Run it and check that `total` kept its answer after `cheaper` was defined. Then add a binding for a discount of 2 and print it beside the price.

Output
Click "Run" to execute your code

let mutable exists, and so does <-. This course uses neither. Once a program can change a value in place, every question about that value becomes a question about when, and the chapters ahead get by without asking it.

A script is read from the top

Every definition joins the scope of the file below the line that declares it. A function may use values and functions defined above it, never below, which is why a helper has to appear before its caller and a type before the record that uses it. A script has no hoisting and no forward declaration to work around that.

Inside a function the rule repeats at a smaller scale. Parameters and local let bindings live in the body, below the point where they are written, and disappear when the function returns. A local name may shadow an outer one; the new definition wins only in the lines that follow it, and the outer value is still there for everything after the function.

The order is therefore a diagram of what depends on what. If a function will not compile because a name is not defined, the fix is usually to move a definition up rather than to reach for a different kind of binding.

What the guarantee buys

Because no later line can change an earlier value, you can replace a name with the expression it was bound to and keep reading. That is what makes a small function safe to extract and a calculation safe to move: the answer travels with the definition instead of depending on when it is read.

The price is that a running total has to be passed along rather than updated in place. The fold in a later chapter is exactly that idea: the state goes in as an argument and comes out as an answer, and nothing is left behind to change. A function that returns a new total is also easier to test, because its answer depends only on its arguments.

At the top level of a script, the definitions are compiled in order into a sequence of statements, so a later let can use an earlier one but not the other way round. In a compiled F# file the same rule holds inside a module, and the module boundary is what lets one file refer to another: the compiler reads the files in the order the project lists them.

Because a binding cannot change, a stack trace or a debugger watch is easier to trust: the value shown for a name is the value the expression had when it was evaluated, not a later state that happens to share the name.

At the top level a second let price is FS0037, not an update; shadowing only works in an inner scope such as a function body. price = 12 is a comparison and answers a bool, so it cannot assign. And a forward reference fails with "not defined" rather than finding the line below.

Try these next

  • Define width and height, then area; print all three, then move area above width and read the error.
  • Write a function that shadows its parameter with a trimmed version, and print the original argument afterwards.
  • Try let total = total + 1 after let total = 10 and explain why the second line cannot work.

A receipt refined in steps

Run it and read the two price lines, then watch the name arrive in capitals. Then add a second local refinement that appends the word `paid` a second time, and show that the argument outside the function is unchanged.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

After let price = 12 and let cheaper = price - 5, what is price?