ECMAScript 6 offers tail call optimization, where you can make some function calls without growing the call stack. This chapter explains how that works and what benefits it brings.
Warning: Even though tail call optimization is part of the language specification, it isn’t supported by many engines and that may never change.
Roughly, whenever the last thing a function does is to call another function then the latter does not need to return to its caller. As a consequence, no information needs to be stored on the call stack and the function call is more of a goto (a jump). This kind of call is named tail call; not growing the stack is named tail call optimization (TCO).
Let’s look at an example to better understand TCO. I’ll first explain how it is executed without TCO and then with TCO.
Let’s assume there is a JavaScript engine that manages function calls by storing local variables and return addresses on a stack. How would such an engine execute the code?
Step 1. Initially, there are only the global variables id
and f
on the stack.
The block of stack entries encodes the state (local variables, including parameters) of the current scope and is called a stack frame.
Step 2. In line C, f()
is called: First, the location to return to is saved on the stack. Then f
’s parameters are allocated and execution jumps to its body. The stack now looks as follows.
There are now two frames on the stack: One for the global scope (bottom) and one for f()
(top). f
’s stack frame includes the return address, line C.
Step 3. id()
is called in line B. Again, a stack frame is created that contains the return address and id
’s parameter.
Step 4. In line A, the result x
is returned. id
’s stack frame is removed and execution jumps to the return address, line B. (There are several ways in which returning a value could be handled. Two common solutions are to leave the result on a stack or to hand it over in a register. I ignore this part of execution here.)
The stack now looks as follows:
Step 5. In line B, the value that was returned by id
is returned to f
’s caller. Again, the topmost stack frame is removed and execution jumps to the return address, line C.
Step 6. Line C receives the value 3
and logs it.
If you look at the previous section then there is one step that is unnecessary – step 5. All that happens in line B is that the value returned by id()
is passed on to line C. Ideally, id()
could do that itself and the intermediate step could be skipped.
We can make this happen by implementing the function call in line B differently. Before the call happens, the stack looks as follows.
If we examine the call we see that it is the very last action in f()
. Once id()
is done, the only remaining action performed by f()
is to pass id
’s result to f
’s caller. Therefore, f
’s variables are not needed, anymore and its stack frame can be removed before making the call. The return address given to id()
is f
’s return address, line C. During the execution of id()
, the stack looks like this:
Then id()
returns the value 3
. You could say that it returns that value for f()
, because it transports it to f
’s caller, line C.
Let’s review: The function call in line B is a tail call. Such a call can be done with zero stack growth. To find out whether a function call is a tail call, we must check whether it is in a tail position (i.e., the last action in a function). How that is done is explained in the next section.
We have just learned that tail calls are function calls that can be executed more efficiently. But what counts as a tail call?
First, the way in which you call a function does not matter. The following calls can all be optimized if they appear in a tail position:
func(···)
obj.method(···)
call()
: func.call(···)
apply()
: func.apply(···)
Arrow functions can have expressions as bodies. For tail call optimization, we therefore have to figure out where function calls are in tail positions in expressions. Only the following expressions can contain tail calls:
? :
)||
)&&
),
)Let’s look at an example for each one of them.
? :
) Both f()
and g()
are in tail position.
||
) f()
is not in a tail position, but g()
is in a tail position. To see why, take a look at the following code, which is equivalent to the previous code:
The result of the logical Or operator depends on the result of f()
, which is why that function call is not in a tail position (the caller does something with it other than returning it). However, g()
is in a tail position.
f()
is not in a tail position, but g()
is in a tail position. To see why, take a look at the following code, which is equivalent to the previous code:
The result of the logical And operator depends on the result of f()
, which is why that function call is not in a tail position (the caller does something with it other than returning it). However, g()
is in a tail position.
,
) f()
is not in a tail position, but g()
is in a tail position. To see why, take a look at the following code, which is equivalent to the previous code:
For statements, the following rules apply.
Only these compound statements can contain tail calls:
{}
, with or without a label)if
: in either the “then” clause or the “else” clause.do-while
, while
, for
: in their bodies.switch
: in its body.try-catch
: only in the catch
clause. The try
clause has the catch
clause as a context that can’t be optimized away.try-finally
, try-catch-finally
: only in the finally
clause, which is a context of the other clauses that can’t be optimized away.Of all the atomic (non-compound) statements, only return
can contain a tail call. All other statements have context that can’t be optimized away. The following statement contains a tail call if expr
contains a tail call.
In non-strict mode, most engines have the following two properties that allow you to examine the call stack:
func.arguments
: contains the arguments of the most recent invocation of func
.func.caller
: refers to the function that most recently called func
.With tail call optimization, these properties don’t work, because the information that they rely on may have been removed. Therefore, strict mode forbids these properties (as described in the language specification) and tail call optimization only works in strict mode.
The function call bar()
in the following code is not in tail position:
The reason is that the last action of foo()
is not the function call bar()
, it is (implicitly) returning undefined
. In other words, foo()
behaves like this:
Callers can rely on foo()
always returning undefined
. If bar()
were to return a result for foo()
, due to tail call optimization, then that would change foo
’s behavior.
Therefore, if we want bar()
to be a tail call, we have to change foo()
as follows.
A function is tail-recursive if the main recursive calls it makes are in tail positions.
For example, the following function is not tail recursive, because the main recursive call in line A is not in a tail position:
factorial()
can be implemented via a tail-recursive helper function facRec()
. The main recursive call in line A is in a tail position.
That is, some non-tail-recursive functions can be transformed into tail-recursive functions.
Tail call optimization makes it possible to implement loops via recursion without growing the stack. The following are two examples.
forEach()
findIndex()