Side effects
Recently, I had a discussion with a colleague about the notion of side effects. I was talking about the functional approaches to work with arrays, and listed three functions: map
, filter
, and reduce
. As my colleague mentioned a fourth one, forEach
, I told that it shouldn't be put in the same category with the previous three functions, as forEach
implies the presence of side effects—while it can be used with an expression free of side effects, it is then perfectly useless to call it this way: calling it or not would make no difference.
My colleague argued that forEach
makes perfect sense in a context of pure functions, giving the following example:
this.forEach(yesno => yesno.toggle());
After all, he said, this call doesn't change any global state or anything like that—all it does it to change the local state of the context object itself. While it looks valid at the first sight, such argument is wrong. This article provides two ways to formally prove that.
1 Proof using side effects propagation logic
1.1 Defining side effect
Wikipedia article on side effects starts with a definition of a side effect, and a few examples. Instead of quoting the article, I would rather put a quote of its original source, Spuler, David A.; Sajeev, A. S. M. (January 1994). Compiler Detection of Function Call Side Effects. James Cook University. CiteSeerX 10.1.1.70.2096:
The term Side effect refers to the modification of the nonlocal environment. Generally this happens when a function (or a procedure) modifies a global variable or arguments passed by reference parameters. But here are other ways in which the nonlocal environment can be modified. We consider the following causes of side effects through a function call:
- Performing I/O.
- Modifying global variables.
- Modifying local permanent variables (like static variables in C).
- Modifying an argument passed by reference.
- Modifying a local variable, either automatic or static, of a function higher up in the function call sequence (usually via a pointer).
The relevant part here is the modification of an argument passed by reference. So:
function isEmpty(arr) {
return arr.length === 0;
}
does not have side effects, because it only accesses the argument, without modifying it. Whereas:
function appendZero(arr) {
arr.push(0);
}
has a side effect, since it alters the original arr
.
Our task now is to determine whether in the previous forEach
example, something is altering its arguments.
1.2 Propagation of side effects
Before going back to the original example, it would be useful to establish how side effects propagate from function to function. Take the following function:
function greet(name) {
console.log(`Hello, ${name}!`);
}
Obviously, this one has side effects, as it performs I/O. Now, let's consider this:
function greetGeorge() {
greet('George');
}
In order to determine whether this function has side effects or not, it is necessary to find whether the function it calls has side effects. In this case, greet
performs I/O, therefore greetGeorge
can be considered as performing I/O. In other words:
Property 1:
A function is considered to have side effects when at least one of its execution paths calls one or more functions which have side effects which go beyond the calling function.
Now, let's rewrite greetGeorge
like this:
function greetGeorge2(action) {
action('George');
}
The action being passed as a parameter now, there is no possible way to determine whether greetGeorge
has side effects or not without considering the actual arguments it receives. I would suggest therefore a rule phrased like that:
Property 2:
A function executing the actions which it receives as arguments is considered to have side effects within a given execution context where one or more actions have side effects.
In other words, I wouldn't consider greetGeorge2
to inherently have side effects; instead, its property of producing side effects depends entirely on its actual execution.
1.3 Decomposing the code
In the original forEach
example, there is one expression, one function, and one prototype:
- The entire expression
this.forEach(yesno => yesno.toggle())
. - The function
yesno => yesno.toggle()
. - The prototype
this.forEach(action)
.
To make things clearer, let's rewrite the function yesno => yesno.toggle()
like this:
function toggleIt(yesno) {
yesno.toggle();
}
Is it free of side effects? Based on Property 1 above, this would depend on what toggle
does. I would guess that toggle
itself is implemented similarly to this:
YesNo.prototype.toggle = function () {
this.underlyingValue != this.underlyingValue;
}
Here, toggle
is a prototype. A prototype is not a function: instead, here, it would be similar to a method in languages such as Java, C#, or Python, i.e. it doesn't live in a vacuum, but has a contextual object attached to it, referred within the prototype by the keyword this
. Functions, by their nature, don't have contextual objects: they have a knowledge of the world around them through the arguments passed to them by the caller. Python's way to write methods is quite explicit: unlike many other languages which hide this
, Python leaves it to the programmer to write it explicitly as a first parameter:
class StorageApi:
...
def _get_uri(self, name):
encoded_name = urllib.parse.quote_plus(name)
return f"{self.storage_api}values/{self._domain}/{encoded_name}"
Back to the toggle
prototype, it would look like this in a form of a function:
const toggle = function (self) {
self.underlyingValue != self.underlyingValue;
}
Now, it is clear that it has side effects: it takes a mutable argument passed by reference, and modifies it. Therefore toggleIt
has side effects, and so does its original variant yesno => yesno.toggle()
.
What's with the prototype this.forEach(action)
? Since in the current case, action
always has side effects, and since forEach
always executes the action
, one can conclude, based on the previously stated Property 2, that in this specific script, forEach
(always) has side effects. Since this.forEach(yesno => yesno.toggle())
calls forEach
, it too has side effects, given Property 1.
2 Proof using referential transparency
An easy way to find whether a function has side effects or not is to make everything constant and immutable, and then imagine a given function being executed.
Let's take an example of a code with no side effects:
function product(arr) {
return arr.reduce((acc, elem) => acc * elem, 1);
}
In a perfectly immutable context, this function would behave the exact same way it does otherwise: it will take an immutable array, and return the product of all the numbers it finds inside it.
Now what about the original:
this.forEach(yesno => yesno.toggle());
As soon as you call it, it will fail. As yesno
is immutable, trying to change its underlyingValue
will throw a TypeError
exception (at least in strict mode).
In short, if a function can work only in a context where at least one of the surrounding objects is mutable, then the function has side effects.
Haskell, for instance, makes a clear separation between pure functions, and everything else—this everything else taking a form of monadic actions. Pure functions not only lack side effects, but also always return the same value given the same arguments. The purity helps reasoning about the code, and it certainly helps the compiler when optimizing it. For instance, a result of a pure function can be cached, with no need for cache invalidation.
The property where the actual call to a function can be replaced by a value that the function returned before is called referential transparency.
Let's imagine for a moment that this.forEach(yesno => yesno.toggle())
is pure. It would mean that the whole expression can be replaced by undefined
—this is the value returned by forEach
. It is obvious, however, that doing so would break the program, as the underlying value of yesno
of the elements of the array would not change any longer. Therefore, the expression does not have referential transparency, and so is not pure.
A pure function has two properties:
- It lacks side effects.
- It always returns the same value given the same arguments.
Since the expression this.forEach(yesno => yesno.toggle())
was proven to be not pure, it means that it lacks at least one of the two properties. Could it be the second one? Not really: forEach
would always return undefined
, which implies that it always returns the same value given the same arguments. So it is necessarily the first property that it lacks: in other words, it has side effects.
3 Call it twice
One reader of this blog told me that there is an easy way to show that the discussed expression has side effects.
One remarkable property of functions which have no side effects is that they can be called several times, without any consequence. The return value may potentially differ (unless the function is also pure), but there should be no other consequences.
Here, it doesn't take long to see that calling
this.forEach(yesno => yesno.toggle())
twice would screw things up. The first call will swap the boolean values; the second one will cancel the effect of the first. This means that by no means the expression can be considered to have no side effects.
Indeed, while not being a formal proof, the “call it twice and see what happens” approach is largely enough to highlight the side effects in the current situation.