What it means for a function to be pure was explored in the first post in this series. The benefits of pure functions were discussed in the second post. This post—the third in the series—explores the concept of immutability, its relation to pure functions and why expressions are necessary when working with immutable data.
Data is mutable if its value can be changed (or "mutated") over time. Data is immutable if its value cannot be changed once it's been created. The following example demonstrates the difference.
let mutable variable = 1 let addVariable x = x + variable addVariable 3 // 4 variable <- 2 // Change the value of variable to 2. addVariable 3 // 5 let constVal = 3 let addConst x = x + constVal // This definition is the same as x + 3. addConst 3 // 6 let constVal = 4 // Bind the value 4 to the symbol constVal. addConst 3 // 6
variable is mutable (as the
mutable keyword signifies). As a result, the
addVariable function which relies on it changes its behaviour when it is assigned a new value using the
constVal is immutable. When used in the definition of
addConst, it's just the same as using its value directly. Because of this, binding the symbol
constVal to a new value doesn't change the behaviour of
addConst. (For curious readers: trying to use the <- operator with
constVal gives a compiler error).
Mutable variables are more complex than immutable values: the value that a mutable variable holds (its "state") can change over time, whereas an immutable value is just a value.
Immutability and pure functions
Mutating a non-local variable or an argument within a function is a side effect of that function. This means that a function that mutates variables that exist outside its scope is impure. Similarly, relying on a non-local mutable variable or a mutable parameter makes a function non-deterministic, hence impure.
As discussed in the last post in this series, pure functions have plenty of benefits. As a result, functional programmers tend to avoid mutation, hence immutability is a sensible default for many F# data types. Even better, if a function only relies on immutable data, the compiler can guarantee that the data isn't mutated, preventing a few ways for a function to be (accidentally or deliberately) impure. In practice, this means that in many F# programs the only source of impurity is I/O.
The need for expressions
When working with mutable data, it's quite common to set an initial value in multiple steps. For example:
let setUpAccount newCustomer = let mutable freeCredit = 0 if isSpecial newCustomer then freeCredit <- 100 ...
As already discussed, working with immutable data is beneficial because it lowers the number of potential sources of impurity, so a natural question arises: how can a programmer do the equivalent of the above code snippet when working with immutable data?
Achieving this relies on expressions.
In F#, common language constructs are expressions. The
if...then...else construct is an expression in F#: when used in code, the whole construct can be evaluated and its value determined. This allows the value bound to a symbol to depend on a condition:
let setUpAccount newCustomer = let freeCredit = if isSpecial newCustomer then 100 else 0 ...
In this example,
if isSpecial newCustomer then 100 else 0 is an expression; its value is
100 if the sub-expression
isSpecial newCustomer evauluates to
true; otherwise, its value is
Aside from things like namespaces/modules/import declarations, type definitions and the let keyword, all F# code is made up of expressions. This makes it easier to get by without using mutable data.
Mutability is a potential source of impurity, so functional programmers prefer to work with immutable data. To make that possible, expressions capturing branching logic are needed. F# provides these, like conditional expressions or match expressions. As a result, F# can make most of its commonly-used data types, such as records and lists, immutable.