Tail Recursion
Recall from lecture that Scheme supports tail-call optimization. The example we used was factorial (shown in both Python and Scheme):
def fact(n):
if n == 0:
return 1
return n * fact(n - 1)
(define (fact n)
(if (= n 0)
1
(* n (fact (- n 1)))))
Notice that in this version of factorial
, the return expressions both use recursive calls, and then use the values for more "work." In other words, the current frame needs to sit around, waiting for the recursive call to return with a value. Then the current frame can use that value to calculate the final answer.
As an example, consider a call to fact(5)
(Shown with Scheme below). We make a new frame for the call, and in carrying out the body of the function, we hit the recursive case, where we want to multiply 5 by the return value of the call to fact(4)
. Then we want to return this product as the answer to fact(5)
. However, before calculating this product, we must wait for the call to fact(4)
. The current frame stays while it waits. This is true for every successive recursive call, so by calling fact(5)
, at one point we will have the frame of fact(5)
as well as the frames of fact(4)
, fact(3)
, fact(2)
, and fact(1)
, all waiting for fact(0)
.
(fact 5)
(* 5 (fact 4))
(* 5 (* 4 (fact 3)))
(* 5 (* 4 (* 3 (fact 2))))
(* 5 (* 4 (* 3 (* 2 (fact 1)))))
(* 5 (* 4 (* 3 (* 2 (* 1 (fact 0))))))
(* 5 (* 4 (* 3 (* 2 (* 1 1)))))
(* 5 (* 4 (* 3 (* 2 1))))
(* 5 (* 4 (* 3 2)))
(* 5 (* 4 6))
(* 5 24)
120
Keeping all these frames around wastes a lot of space, so our goal is to come up with an implementation of factorial that uses a constant amount of space. We notice that in Python we can do this with a while loop:
def fact_while(n):
total = 1
while n > 0:
total = total * n
n = n - 1
return total
However, Scheme doesn't have for
and while
constructs. No problem! Everything that can be written with while and for
loops and also be written recursively. Instead of a variable, we introduce a new parameter to keep track of the total.
def fact(n):
def fact_optimized(n, total):
if n == 0:
return total
return fact_optimized(n - 1, total * n)
return fact_optimized(n, 1)
(define (fact n)
(define (fact-optimized n total)
(if (= n 0)
total
(fact-optimized (- n 1) (* total n))))
(fact-optimized n 1))
Why is this better? Consider calling fact(n)
on based on this definition:
(fact 5)
(fact-optimized 5 1)
(fact-optimized 4 5)
(fact-optimized 3 20)
(fact-optimized 2 60)
(fact-optimized 1 120)
(fact-optimized 0 120)
120
Because Scheme supports tail-call optimization (note that Python does not), it knows when it no longer needs to keep around frames because there is no further calculation to do. Looking at the last line in fact_optimized
, we notice that it returns the same thing that the recursive call does, no more work required. Scheme realizes that there is no reason to keep around a frame that has no work left to do, so it just has the return of the recursive call return directly to whatever called the current frame.
Therefore the last line in fact_optimized
is a tail-call.