|From Get Programming with F# by Isaac Abraham
This article explores working with mutable data structures to create objects and modify their state through operations.
Working with mutable data
Working with mutable data structures in the OO world follows a simple model – you create an object, and then modify its state through operations on that object.
Figure 1 Mutating an object repeatedly
What’s tricky about this model is that it can be hard to reason about your code. Calling a method like UpdateState() above will generally have no return value; the result of calling the method is a side effect that takes place on the object.
Now you try
Let’s now put this into practice with an example – driving a car. We want to write code that allows us to drive() a car, tracking the amount of petrol used; the distance we drive determines the total amount of petrol used.
Listing 1 Managing state with mutable variables
let mutable petrol = 100.0 ❶ let drive(distance) = ❷ if distance = "far" then petrol <- petrol / 2.0 elif distance = "medium" then petrol <- petrol - 10.0 else petrol <- petrol - 1.0 drive("far") ❸ drive("medium") drive("short") petrol ❹
❶ Initial state
❷ Modify state through mutation
❸ Repeatedly modify state
❹ Check current state
Working like this, it’s worth noting a few things –
- Calling drive() has no outputs. We call it, and it silently modifies the mutable petrol variable – we can’t know this from the type system.
- Methods aren’t deterministic. You can’t know what the behaviour of a method is without knowing what the (often hidden) state is, and if you call drive(“far”) 3 times, the value of petrol will change every time, depending on the previous calls.
- We’ve no control over the ordering of method calls. If you switch the order of calls to drive(), you’ll get a different answer.
Working with immutable data
Let’s now compare that with working with immutable data structures.
Figure 2 Generating new states working with immutable data
In this mode of operation, we can’t mutate data. Instead, we create copies of the state with updates applied, and return that to the caller to work with; that state may be passed in to other calls that generate a new state yet again.
Performance of immutable data
I often hear this question – isn’t it much slower to constantly make copies rather than modify a single object? The answer is: yes and no. Yes, it’s slower to copy an object graph than make an in-place update. Unless you’re in a tight loop, performing millions of mutations, the cost of doing it is neglible compared to opening a database connection. Plus, many languages (including F#) have specific data structures designed to work with immutable data in a highly performant manner.
Let’s now rewrite our code to use immutable data.
Listing 2 Managing state with immutable values
let drive(petrol, distance) = ❶ if distance = "far" then petrol / 2.0 elif distance = "medium" then petrol - 10.0 else petrol - 1.0 let petrol = 100.0 ❷ let firstState = drive(petrol, "far") ❸ let secondState = drive(firstState, "medium") let finalState = drive(secondState, "short") ❹
❶ Function explicitly dependent on state – takes in petrol and distance, and returns new petrol
❷ Initial state
❸ Storing output state in a value
❹ Chaining calls together manually
We’ve made a few key changes to our code. The most obvious is that we aren’t using a mutable variable for our state any longer, but a set of immutable values. We “thread” the state through each function call, storing the intermediate states in values, which are manually passed to the next function call. Working in this manner, we gain a few benefits immediately.
- We can reason about behaviour more easily. Rather than hidden side effects on private fields, each method or function call can return a new version of the state that we can easily understand. This makes unit testing much easier, for example.
- Function calls are repeatable. We can call drive(50, “far”) as many times as we want, and it’ll always give us the same result. This is known as a pure function. Pure functions have useful properties, such as being able to be cached or pre-generated.
- The compiler protects us, in this case, from accidentally mis-ordering function calls, because each function call is explicitly dependent on the output of the previous call.
- We can see the value of each intermediate step as we “work up” towards the final state.
Passing immutable state in F#
In this example, you’ll see that we’re manually storing intermediate state and explicitly passing that to the next function call. That’s not strictly necessary, as F# has language syntax to avoid having to do this explicitly.
Now you try
Let’s try to make some changes to our drive code.
- Instead of using a string to represent how far we’ve driven, use an integer.
- Instead of “far”, check if the distance is more than 50.
- Instead of “medium”, check if the distance is more than 25.
- If the distance is > 0, reduce petrol by 1.
- If the distance is 0, make no change to the petrol consumption. Return the same state that was provided.
Other benefits of immutable data
A few other benefits that aren’t necessarily obvious from the above sample:
- When working with immutable data, encapsulation isn’t necessarily as important as it is when working with mutable data. Sometimes encapsulation is still valuable, e.g. as part of a public API – but there are occasions where making your data read-only removes the need to “hide” your data;
- Multi-threading. One of the benefits of working immutable data is that you don’t need to worry about locks within a multi-threaded environment. Because there’s never any shared mutable state, you don’t need to be concerned with race conditions – every thread can access the same data as often as necessary, without change.