🖥️ What Is A Pure Function, Anyway?
Recently we have been observing somewhat of a paradigm shift in the programming world. A shift from imperative to a functional way of doing things. This change has coincided with not only a different way of writing code but also a different way of architecting applications altogether. Admittedly, I was quite late in catching this train and indeed I was pretty skeptical about the real-world benefits of such an approach.
There’s quite a lot of new concepts and terminology to grasp in an already jargon-heavy industry. The origins of functional programming are actually in mathematics. There’s just one problem: I suck at maths. Therefore, it is my pleasure to try and explain the pure function, one of the simplest functional programming concepts without relying on mathematical notation and too much fancy wizardry.
An Impure Function
Before we take a look at understanding what a pure function is, let’s solidify our understanding of what is not a pure function. Take the following example:
const sayHello = () => {
console.log("Hello!");
};
Is sayHello()
a pure function? I’ll tell you: no. How do we know? We can determine that this is an impure function because it has side-effects. There are consequences to invoking it. Let’s take a look at this new term means.
Side Effects
Any useful piece of software has side-effects or consequences. You click on a button and a new screen appears. You type into a text-box and the characters you entered appear on the screen. You fire off an RPG and the Strogg explodes into a mist of blood. These are side-effects, and these are the reason we write software to begin with! After all, an application without side-effects will be fairly useless in the real world.
So why then, is sayHello()
an impure function? When it is invoked it prints the string “Hello!” into the console. This seemingly benign and friendly function is actually an enemy of functional programming because it has side-effects. The act of printing characters into a console is a basic example of such an effect but you can easily see how this could scale to more exciting examples such as:
- Writing some data to a file.
- Playing a sound effect.
- Exploding a Strogg into a bloody mist.
Admittedly, this is somewhat of a contrived example. However: there is an even more heinous and terrifying example of an impure function: a function that does not behave consistently given the same input arguments.
An Even Dirtier Function
Impure functions have side-effects. However, at least we can rely our sayHello()
example above to remain friendly and say “Hello!” every time we invoke it. As I mentioned there is an even scarier example: a function that cannot be relied on to behave consistently given the same arguments.
Let’s have a look at another example:
let counter = 0;
const addNumbers = (a, b) => {
++counter;
return counter < 5 ? a + b : null;
};
At first glance addNumbers()
is a mostly harmless function. You invoke it and it returns the some of the a
and b
arguments that you pass in. Invoke it a few times. Works great doesn’t it? Nope! This function only works the first four times you invoke it. After that it always returns null. This function is impure and we cannot rely on it to behave consistently. Not only does it behave inconsistently - it also mutates shared state. What happens if another function is also relying on the counter
variable? We have also inadvertently introduced temporal coupling into our application because the order in which its constituent functions are invoked will have an impact on the outcome of the program.
The above example is intentionally contrived. However, there will be many real-world examples of functions in real-world software applications which do not behave consistently each time they’re invoked. All of these functions will have something in common: they will all rely on external dependencies other than the arguments that are directly passed into them.
So, impure functions are much like that unreliable friend: you cannot depend on them to behave consistently and having them in your life is sure to have consequences.
A Pure Function
Let’s recap on what we’ve learned so far:
- An impure function may not behave consistently. Invoking it with the same arguments more than once may not have the same return value. This is typically due to having dependencies other than those past in as arguments.
- An impure function may have side effects and consequences. This could be as benign as printing out some characters to a console.
Now we are ready to define the pure function: A pure function is a function that:
- Has no side-effects.
- Behaves predictably and consistently: it always returns the same value given the same input arguments.
Let’s reimplement our addNumbers()
function to be pure:
const addNumbers = (a, b) => {
return a + b;
};
Each time we invoke addNumbers()
with the same arguments it will always return the same value. Every time. Not only that but it doesn’t have any side-effects, nor does it mutate any shared state. It depends only on the inputs that are passed into it as arguments.
Why Be Pure (All The ‘bles)?
What’s all this fuss about pure functions then? Truly understanding all the benefits of pure functions is beyond the scope of this basic introduction and will take time to sink in. However, allow me to broadly cover the advantages by explaining the ‘bles.
Some of the standout properties of pure functions are that they’re: composable, cacheable, testable and parallelizable.
Composable
Due to the reliable nature of pure functions they can readily be substituted as arguments into other functions. Consider the following example which utilises the pure version of addNumbers()
:
const addMoreNumbers = () => {
return addNumbers(addNumbers(1, 1), addNumbers(2, 2));
};
The result of invoking addMoreNumbers()
is 6. It’ll always be 6. In fact, because of the reliable nature of pure functions we can directly replace the invocation of a pure function with its return value (this is where we could get really fancy with some mathematical notation!). This is because addNumbers(1, 1) === 2
and addNumbers(2, 2) === 4
.
const addMoreNumbers = () => {
return addNumbers(2, 4);
};
The above two implementations of addMoreNumbers()
are functionality equivalent. The composability of pure functions is perhaps the most beautiful and powerful property. Understanding the true advantages of this will take some time and hopefully that is something we can explore in another article.
Cacheable
Perhaps, a less obvious benefit of pure functions is that we can cache the result. Since we can rely on a pure function to always return the same result given the same input we can reliably cache the result, safe in the knowledge that it will never change. There would be little benefit in caching the result of our addNumbers()
example from above because invoking it directly requires little computational effort. However, you could imagine a far more expensive pure function that does something computationally intensive. This technique is typically referred to as memoization.
Testable
One of the truly great properties of pure functions is that they’re easy to test. We can rely on these beauties to always do the same thing. They do not have loads of external dependencies. In fact, they’re even better: their dependencies are explicit. We know all of the dependencies of a pure function because they’re provided as arguments. In our addNumbers()
example above, a
and b
are the explicit and only dependencies of the function. With this knowledge in mind, pure functions are great candidates for unit testing. We can built up a suite of useful tests against our pure functions and know that these tests will always have the same outcome. This makes it very easy to test. Anyone who has experienced the pains of testing a stateful system will know that tests against such systems are often difficult to write, brittle and sometimes flaky.
Parallelizable
Is this even a real word? Apparently so! Modern computing is all about doing things in parallel or concurrently. Perhaps you have observed that CPU performance is no longer governed by raw processor clock-speed but also by the number of logical cores of execution it has. Even your mobile phone will likely include a multi-core CPU. By exploiting the implicit properties of a pure function we can take advantage of this: since a pure function always does the same thing given the same inputs and has no impact on shared state we can split the computation of large amounts of data into chunks and execute them in parallel.
All of the above points and many more make pure functions pretty awesome things and the fundamental building blocks of functional programming.
In Conclusion
I hope you enjoyed our whirlwind tour of pure functions! I also hope that this is just the beginning of your adventures in the world of functional programming. Together we’ve learned that pure functions are the crucial building blocks of functional programming. They’re reliable and dependable like an faithful old dog. They’re predictable like a Dad with his terrible jokes. They’re composable, cacheable, testable and parallelizable and they’re fun to write and use!
In future posts I’d like to explore more introductory topics of functional programming and how this can be applied in developing real-world software applications and more specifically: mobile apps.