It’s closer to what an optimizing compiler does
Back in the olden days, on compilers that couldn’t optimize worth a DRAM, it was necessary to tell the compiler to throw out the old value we no longer needed, and replace it with an updated one. Early programming languages tried to stay close to the low-level operations of the processor, which would update a variable in a register or memory. But modern compilers perform dependency analysis, code movement and register allocation. They’re better at figuring out when they can drop or spill a variable from a register than a human is. So you can now declare every variable you need as a static single assignment, and let the compiler transform your high-level computation into efficient code.
Traditional functional languages transform code into continuation-passing style, moving blocks of code around so that the computations execute after their dependencies. They also allocate registers so that any quantity that’s no longer needed gets discarded, freeing up a register for another quantity that will be needed soon. They do this better than humans ever could: if you keep around a variable that you no longer need, you wast a register, and if you drop it too soon, you cause a bug the compiler can’t catch.
Unmodifiable variables are in many ways easier to optimize, since you’ll know for sure that you can do the computation once and re-use the value, you know for sure when it’s safe to use a reference instead of cloning the object, you know for sure on modern processors that reading the variable is thread-safe, and so on. Some computer scientists go further and try to prove that certain program transformations are provably correct on pure functions, in category theory.
Even many compilers for procedural languages transform code like x = x + 1
into a static single assignment, equivalent to x_1 = x + 1
.=, and then treat x
and x_1
as if they were different, immutable variables with a dependency between them. More complicated expressions, such as conditional assignments, get transformed into phi functions on the right-hand side. There’s an efficient algorithm to transform an abstract syntax tree in SSA form into one in CPS form and then apply all the optimizations from that.
If the optimizer is going to try to produce good code by transforming the program into a series of pure computations anyway, it’s less likely to get confused if the source code is written that way.
It prevents certain bugs
In this pseudocode,
y = f(x)
if (a > b)
x = x + 1
z = g(x)
What is the value of x
when z
is computed? Are we sure that x
always gets properly initialized? If this code gets refactored so z = g(x)
gets moved right after y = f(x)
, which you might want to do for clarity, its meaning sometimes changes, giving you a Heisenbug. If you’re reviewing a large function with many possible paths of execution, when we see x
being used, how was it set in this context?
In a functional language (or even a hybrid like Rust), you might write something like
let y = f(x0)
x1 = if (a > b)
then x0 + 1
else x0
z = f(x1)
In many languages, you could even initialize z
before x1
, and there will be no doubt what you meant. Declaring x0
and x1
is completely unambiguous and the meaning of the same symbol does not change if you move it elsewhere in the same block. You can also be certain that x0
and x1
were initialized once and only once, and what they were initialized to.
It’s easier to analyze
It isn’t, in general, possible to deduce whether a code path in an arbitrary program is reachable. However, if you restrict the language so that only certain safe operations are possible, you can more safely prove what will and won’t happen. For example, when variables are immutable, many complex special cases involving modification through an alias cannot occur. C++ even introduced a special form of immutability, constexpr
, specifically to make it possible for the compiler to perform calculations on constants at compile time and fold them into the executable.
Or, let’s take the great example you gave:
def numsum1(n):
sum = 0
i = 1
while i <= n:
sum = sum + i
i = i + 1
return sum
We want an optimizing compiler to be able to tell that this is all equivalent to return n
. Optimizers for imperative languages are heavily-focused on analyzing loops, and this while
loop is simple enough that I’d expect a modern C, C++, Java or Rust compiler to be able to figure the translation out.
Functional languages have equivalents
A functional language can also have variables that mutate within each iteration of a loop, only it would refactor into a tail-recursive function:
numsum1 n = go 1 0 where
go (n+1) sum = sum
go i sum = go (i+1) (sum+1)
Or, if we’re not worried about negative numbers as inputs (although we could easily handle those too):
numsum1 n = go 0 n where
go sum 0 = sum
go sum i = go (sum+1) (i-1)
Tail recursion on a pattern match like this is exactly equivalent to the loop: you perform a test at the beginning of each repetition to see whether you continue, and if so, increment the loop counter (which on a modern ABI means updating registers and re-using the same stack frame) and jump back to the start of the loop/tail-recursive function. Both versions have a local variable named i
within the block of code whose value changes every time it runs. Since imperative-language compilers also want to be able to optimize tail-recursive functions, it’s simpler for the compiler to be able to rely on a great optimizer for recursive code, and not need to analyze arbitrary while
loops too.
sum
andi
are initialized to a value on entry to the function call, and cannot be changed in that scope. $\endgroup$i
in one call is a completely separate variable fromi
in another. $\endgroup$numsum1
is not referentially transparent, as knowing the values ofsum
andi
in the scope ofnumsum1
depends on knowing how many times the loop has iterated so var.numsumstep
is transparent because you only need to know the values passed to a particular call. $\endgroup$