Making short work of pure function misconceptions
Chances are, you heard of functional programming, and maybe dabbled in it. But if it is not your main paradigm, you probably have some misconceptions about it.
First of all, a disclaimer: I am still new to FP myself, but I have already wrestled with understanding it for at least two years, and the amount of misunderstandings I had during this time is staggering. My goal here is to illuminate these errors of judgement, before I fall victim to the Curse of Knowledge
First, I will give you a valid definition of a pure function, as you can find it in countless articles:
A function is pure, if:
- its result is determined solely by its parameters (referential transparency)
- it does not rely on outside mutable state
- it does not perform any side effects
Now, let's try to understand what this means, and equally importantly, what this doesn't mean, by looking at a number of examples.
In order to solve them, you need to understand three things about Scala:
val
creates an immutable variable. It cannot change after it was createdvar
create a mutable variable. You can reassign to it- the last expression of a function in Scala is its return value
Example 1
def add(a: Int, b: Int): Int =
println("calculating $a + $b")
a + b
Solution
❌ add is not pure!
Concerns and Explanation
Why you may think it's pure:
- add() always returns the same result, given the same parameters. It does not rely on outside state and does not change the program. So clearly it is pure.
Why it is not pure:
- the call to println() does not contribute to the computation of the function result.
- println() actually tries to "send data to the outside world", which is strictly forbidden from a purity standpoint.
Example 2
def add(a: Int, b: Int): Int = a + b
Solution
✅ add is pure!
Concerns and Explanation
- add() will always produce the same result if called with the same parameters, e.g add(3, 4) will always return 7
Example 3
var a = 2
var b = 3
...
def add() = a + b
Solution
❌ add is not pure!
Concerns and Explanation
a
andb
are defined asvar
, therefore their signature says they are allowed to change. calling add() multiple times may lead to different results, depending on what else happens in the program.
Example 4
val a = 3
val b = 4
...
def add() = a + b
Solution
✅ add is pure!
Concerns and Explanation
Why you might think it is impure:
- "a pure function's return value is determined solely by its parameters" - but here, clearly the result depends on outside variables!
- If someone changes the source code outside of add(), its result will change! It is not independent!
Why it really is pure:
- The result is still determined by the parameters, i.e. only the parameters decide whether the result will change between calls. Purity does not prescribe anything about how much code a function can depend on, as long as that code itself consists of immutable values.
a
andb
are defined asval
, which is immutable in Scala. No matter how many times you call add(), no matter what else happens in the program, the answer will always be 7.- there are no side effects
Lessons from this example
- function purity is NOT concerned with source code changes
- function purity is NOT concerned with outside (immutable) dependencies
Example 5
val a = Random.nextInt()
...
def add(b: Int): Int = a + b
Solution
✅ add is pure!
Concerns and Explanation
Why you might think it is impure:
- calling add(), even without source code changes, may lead to different results when running the program again and again. Two users will get different results from calling the same function in the same way.
Why it really is pure:
- add() relies on
a
which is immutable. It is functionally the same as Example 4. - The program as a whole is impure, because Random.nextInt() is not referentially transparent. However, once
a
is set in place (which it is at the point where add() is created), add() will always return the same result when called with the same parameters, so add() itself has to be considered pure.
Lessons from this example
- function purity is NOT concerned with whether the function produces different results in another instance of the program.
- Referential transparency of a function is always in relation to the fact that in a given program state, it will always produce the same result, (for the entire runtime of the program, but not necessarily between multiple program runs.
Example 6
class WeirdCalculator(private val a: Int)
def add(b: Int): Int = a + b
Solution
✅ add is pure!
Concerns and Explanation
Why you might think it is impure:
- This code uses a class! Add references a member of an object. It is not self-contained
Why it really is pure:
- The same reasons as examples 4 and 5. The referenced value is immutable, referential transparency is not violated.
Lessons from this example
- (immutable) OOP and purity are actually compatible!
Example 7
Is makeGreeting() pure?
def makeGreeting(sb: StringBuilder, name: String): String =
sb.append(s", $name!").toString()
val sb = new StringBuilder("Hello")
val greeting = makeGreeting(sb, "Jane")
// "Hello, Jane!"
Solution
❌ makeGreeting is not pure!
Concerns and Explanation
Why you might think it is pure:
- Calling makeGreeting("Jane") will always produce "Hello, Jane!"
- makeGreeting() only relies on its parameters
- the parameters are immutable
Why it is actually not pure:
- Fun fact: This code has a bug. If you call makeGreeting("Jane") twice in a row, the second time it will produce "Hello, Jane!, Jane!" This is because we forgot to call clear() on
sb
between calls. - makeGreeting() meddles with
sb
, which despite being aval
, has mutable state attached to it (the actual string we are building). Since sb is passed from the outside, and we change state on it, we perform side effects, that can lead to subtle bugs!
Lessons from this example
- Passing a
val
into a function can make it impure. Namely, when saidval
hasvar
members and our function actually causes a state change to propagate to the outside via a side-channel (not the return value).
Where does this leave us?
From what we learned above, we have to make the following realizations:
- a pure function does not guarantee that we can look at a function locally and know (cognitively) what it does (Ex 4)
- a pure function does not guarantee that it produces the same result if external source code changes (Ex 4)
- a pure function does not guarantee that it produces the same result on multiple program runs (Ex 5)
- purity is not concerned with "clean code" in the sense of "locally groggable code". Pure expressions can be arbitrarily complex. Pure code may be reduced to a single value mathematically speaking, but in practice nothing prevents you from referencing 5000 immutable global variables from inside a pure function, which is arguably hard to read and maintain for a mere mortal.
So, what does function purity grant us, on its own?
- Within a given program run, provided the same parameters, a pure function will always produce the same result
- Any call to a pure function can be replaced with its result. This means, you can jump to arbitrary levels of abstraction in order to debug a program and never have to worry about forgetting some steps. The order in which functions are called is not important.
- Gives a whole new ring to the word refactoring
- Since there are no side effects, you can call a pure function, no matter how complex, without worring about accidentally sending missiles.
If a variable is mutable, it exists in an additional dimension.
In other words: pure code is concerned with removing "sequence" or "time" out of the equation of the context of program execution. If a variable is mutable, it exists in an additional dimension. You have to track its value every time, everywhere it may be referenced. Whereas pure code completely frees you from this burden and condenses code into a single dimension: composition of values, frozen in time.
Bonus: It appears even ChatGPT struggles with the concept. Here is an excellent conversation highlighting the issue https://chatgpt.com/share/676628e5-ae04-800b-950d-8a97e275ebe1
Language version used: Scala 3.6.2
Next up
"How to maximize pure code in an impure program?" There is a good chance you actually need to print() sometimes, let alone read from a database, write to a file, get a datetime, get input from a keyboard, and so on!
If you do not want to miss upcoming articles, consider subscribing to the newsletter!