Why Global Mutations Are Confusing
By ANTHONY KHONG
In the previous parts, we have spoken about the “what” of FP. In this section, we will start covering the “why”. Before getting into the general benefits of pure functions, we look into how exactly global mutations make our code confusing by creating “moving parts”.
Consider the following Python code:
>>> def g(xs): ... ??? ... >>> def f(xs): ... return xs + [999] ... >>> xs = [1, 2, 3] >>> ys = g(xs) >>> f(xs) # Out: ???
Let’s imagine that we are dealing with a large codebase, and we cannot understand it in its entirety. The function g
is written in a distant part of the codebase, and we are focusing our attention on the function f
. By the looks of it, f
is a simple function that takes a list, and creates a new list with an additional element of 999 at the tail. The question is, can we know for sure what the last line evaluates to?
We may expect the answer to be [1, 2, 3, 999]
. However, absent any other information, the answer should be “no”. This is because we cannot guarantee that the function g
does not mutate the list xs
. Consider the following implementation of g
:
>>> def g(xs): ... xs.reverse() ... return xs ... >>> def f(xs): ... return xs + [999] ... >>> xs = [1, 2, 3] >>> ys = g(xs) >>> f(xs) # Out: [3, 2, 1, 999]
In this case, f(xs)
evaluates to something other than the obvious answer. In contrast, consider two other implementations of the function g
, which on paper, ought to do the same thing. Firstly:
>>> def g(xs): ... xs = xs[:] ... xs.reverse() ... return xs ... >>> def f(xs): ... return xs + [999] ... >>> xs = [1, 2, 3] >>> ys = g(xs) >>> f(xs) # Out: [1, 2, 3, 999]
and secondly:
>>> def g(xs): ... return xs[::-1] ... >>> def f(xs): ... return xs + [999] ... >>> xs = [1, 2, 3] >>> ys = g(xs) >>> f(xs) # Out: [1, 2, 3, 999]
In the first case, the f(xs)
call results in an unexpected answer, whereas the other two cases do not. How are these implementations different? To answer this question, we make the distinction between a global mutation, a local mutation and no mutation at all:
Global Mutation | Local Mutation | No Mutation | |
---|---|---|---|
Code | xs.reverse() | xs = xs[:]; xs.reverse() | xs[::-1] |
xs declared locally | $\times$ | $\checkmark$ | $\times$ |
xs not mutated | $\times$ | $\times$ | $\checkmark$ |
Expected return | $\times$ | $\checkmark$ | $\checkmark$ |
Here, “declared locally” means that the variable xs
that is eventually returned is declared/initialised within the function scope. In this case, the function has control on the starting value of the variable. “Mutation” refers to whether we change the content of the variable xs
that is eventually returned. The table above tells us that we only get the unexpected results only if we made a global mutation.
I like to illustrate the situation with a simple diagram:
In short, a global mutation causes a change of some value outside the scope of the function, which is visible by other functions. An application is composed of many, many function calls. If every other function call reaches out to the global scope and perform mutations, we end up creating moving parts that is increasingly difficult to keep track of.
By working with pure functions, we do not have to deal with moving parts as described above. As one famous tweet puts it, “OO makes code understandable by encapsulating moving parts. FP makes code understandable by minimizing moving parts.”