FP Core concepts: Pure functions & Side-Effects

Balraj Singh - Feb 9 '23 - - Dev Community

What are pure functions?

A pure function is

  1. Referential transparency, meaning they consistently yield the same results given the same input, irrespective of where and when they are invoked. That is, function evaluation depends less - ideally, not at all - on the side effects of mutable state.

  2. Produce no side-effects

Advantages of pure functions

  • Simpler to reason about
  • Easier to combine
  • Easier to test
  • Easier to debug
  • Easier concurrency

What are side-effects?
Side-effect is some extra work done when calling a function which changes something in the world around it. Lets take an example to understand it better.
Problems with side-effects
Source of complexity
Cannot be tested
Cannot be composed

Lets create a pure-function i.e function without side-effect first.
func squareValue(_ x: Int) -> Int {
return x * x
}
squareValue(3)

// Now we can test this
assert(squareValue(2) == 4, "Failure")
assert(squareValue(3) == 9, "Failure")
assert(squareValue(4) == 16, "Failure")

Lets analyse the above function on following points:

Testability
In the above example we can see that we can test pure function easily.
The value of output only depends on input and no other variable. Hence it will always yield the same result for same set of input.

Debugging
Also the output remains same for the same set of input which makes this function predictable and easier to debug. There is no external state which this function effects or is effected by. So it becomes easier to debug as well.

Thread-Safety
Since the above function don't share any common resource neither is effected by any outside value or effect any global state. This makes this function thread-safe.

On introducing side-effect in the same function yield to an impure function which looses all these advantages. Lets see a function with side-effects

// Now lets add some side-effect to the above func
func squareValueWithSideEffect(_ x: Int) -> Int {
// the print statement is side-effect here
print("calculated value is \(x * x)")
return x * x
}

squareValueWithSideEffect(2)

// Now we can test this
assert(squareValueWithSideEffect(2) == 4, "Failure")
assert(squareValueWithSideEffect(3) == 9, "Failure")
assert(squareValueWithSideEffect(4) == 16, "Failure")

Lets analyse the above function on following points:-

Testability
The above example we have an additional behaviour that we cannot test anymore. This is a side-effect for this function.

Debugging
Side-effect can be printing values on the screen, storing a value, making an API call, logging analytics etc. These behaviours cannot be tested hence can be source of complex issues and difficult to debug also.

Thread-Safety
This function is no more thread-safe as it is effecting a behaviour in outside world. With multiple threads trying to operate over this function will lead to a random order of print statement execution. Which will always yield a different result while printing the values on the screen. Side-effect also hurt composability & optimisation. As shown in the below example:-

`// These will give the same result.
// This happens by composing the 2 functions together since they are pure functions without any side effects
[5,10].map(squareValue).map(squareValue)
//This one is more optimised than the first one as it iterate over the array once.
[5,10].map { squareValue(squareValue($0)) }
// Now lets check the same with a function having side-effects
[5,10].map(squareValueWithSideEffect).map(squareValueWithSideEffect)
print(" - - Order changed - - ")
[5,10].map { squareValueWithSideEffect(squareValueWithSideEffect($0)) }
Output of the above code snippet:
calculated value is 25
calculated value is 100
calculated value is 625
calculated value is 10000

  • - - Order changed - - * calculated value is 25 calculated value is 625 calculated value is 100 calculated value is 10000`

As you can see that the order of the print changed in the above example. Even though the return value remained the same. This may not seem to be a problem for now. But if the order mattered in the case of side effect like Logging analytics data or making API call or updating the value of a class level variable then this might hurt badly.
As we can see composing functions with side-effect can land us into troubles which are hard to find and fix.

How to Fix this?
To fix this we can start returning back the value for the side-effect along with the actual value to be returned. This way we can push side-effect out to the boundary. As a result side-effect can be controlled properly. We will be aware about which piece of code is doing side-effect and which piece of code is pure in nature.

To understand the above thought here is an example
`[5,10].map {
squareValueWithSideEffect($0)
}.map {
let r2 = squareValueWithSideEffect($0.calculatedValue)
print($0.valueToPrint)
print(r2.valueToPrint)
}
[5,10].map{
let r = squareValueWithSideEffect($0)
let r2 = squareValueWithSideEffect(r.calculatedValue)
print(r.valueToPrint)
print(r2.valueToPrint)
}
The output of the above example:-
25
625
100
10000

  • - Order changed - - - 25 625 100 10000`

As seen above the results of print statement is now in same order. Let see what happened here.
We created a new function which now returns 2 values. 'valueToPrint' is value used to perform side-effect and 'calculatedValue' is the value that will be returned as a result from this function. By moving side-effect out of this function and returning the value that will be used by the side-effect, this function becomes pure.
Since the function is pure again so it has all the advantages of a pure function.

Testability
Now we can completely test this function again. Also we can test the value used by side-effect. Doing so makes our function completely testable.

Debugging
Since there is no side-effect in the function. This function always returns the same value for same set of inputs. Hence making debugging easy

Thread-Safety
The function again becomes thread-safe as there is no print statement inside the function hence the order of execution doesn't matter here.
Also there is no shared resource to worry about which can land us into threading issues.

Composability still an issue!!!
Even though we might have solved the issue of making an impure function back to pure function and containing all the side-effects at the boundry.
We are still left with one big issue. The new function that we created later to return 2 values in a Tuple is no more compassable.

[5,10].map{
let r = squareValueWithSideEffect($0)
let r2 = squareValueWithSideEffect(r.calculatedValue)
print(r.valueToPrint)
print(r2.valueToPrint)
}

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .