One of the commonly cited advantages of F#, and functional programming languages in general, is the emphasis on purity.
When we say a function is ‘pure’, we mean that for any given input, we will always get the same output.
This is beneficial to us because it makes things very simple to understand and test.
There are no hidden side-effects when we execute the function, and we can be confident that we will get consistent results from run to run.
Some languages such as Haskell are very strict with purity. Any function which is not pure must declare itself so using the ‘IO’ type, and any function which uses it becomes itself impure.
F# does not impose such restrictions. It is up to the programmer to understand how their code is behaving.
Let’s look at some of the options available to us.
Values as arguments
To make sure your function is pure, you must follow two simple rules –
- Only operate on immutable values which are passed in as arguments.
- Only call other pure functions.
This is to guarantee that there are no side effects from your function.
let add a b = a + b
add 5 2
val it : int = 7
Obviously, all programs must have side effects eventually – they wouldn’t be much use if they didn’t have any output to the real world.
The general principle is that we try to create a kind of impure-pure-impure sandwich, where we gather data from the outside world, pipe it through a series of pure transformations and then emit the result to the world at the very end. This way we maximise the pure part of our system.
For a deeper exploration of this idea, I highly recommend reading a series of blog posts from Mark Seemann titled 'From dependency injection to dependency rejection'.
Functions as arguments
If you have any background in object-oriented programming, you will almost certainly have come across dependency injection.
This is where you pass an object all the other objects it needs to do its job at the moment you construct it.
This can be a useful approach as it allows you to mock external services easily for testing purposes, and can allow dynamic reconfiguration of an application.
The equivalent to this in F# is – well, functions, just like everything else!
More precisely, you can pass a function as a parameter to another function. This is known as passing in a higher order function, and used along with partial application allows you to ‘construct’ different implementations of the function.
The downside of this approach is that you can’t guarantee that all uses of the function will be pure. It depends on the arguments passed in.
If you only pass pure functions in, then you will still have a ‘pure’ function, but this is not always easy to track throughout the system and over time.
open System
let operate operator a b = operator a b
operate (+) 3 6
val it : int = 9
operate (fun a b -> a + b + DateTime.Now.Second ) 3 6
val it : int = 52
operate (fun a b -> a + b + DateTime.Now.Second ) 3 6
val it : int = 54
It is therefore a good idea to use this approach sparingly, once again trying to keep it at the edges of the system. If you have an impure-pure-impure sandwich, then this is where you will need to be mocking services anyway.
Objects as arguments
You can, of course, pass a full object as an argument, replete with its own internal state and multitude of methods.
This approach bears many similarities to the ‘functions as arguments’ approach, and shares many of the same drawbacks, if anything somewhat amplified.
open System
type Calculator (created : DateTime) =
member this.AddSeconds (a : int) = a + created.Second
let addSeconds (calculator : Calculator) a = calculator.AddSeconds a
addSeconds (Calculator(DateTime.Now)) 5
val it : int = 31
addSeconds (Calculator(DateTime.Now)) 5
val it : int = 32
Conclusion
If you come from an object-oriented background then purity may not be something you have spent much time considering.
F#’s pragmatic nature allows you to mix and match pure and impure code freely. This puts the responsibility of tracking side effects with the programmer.
If you spend some time thinking about how you write your code, and favour purity wherever possible, you will find that your software is easier to reason about and test effectively.