Tail-call elimination

In previous labs, we've seen several examples illustrating the idea of separating the recursive kernel of a procedure from a husk that performs the initial call. Sometimes we've done this in order to avoid redundant precondition tests, or to prevent the user from bypassing the precondition tests. In other cases, we saw that the recursion can be written more naturally if the recursive procedure has an additional argument, not supplied by the original caller.

There is yet another reason for adopting the husk-and-kernel approach, and it has to do with efficiency. An implementation of Scheme is supposed to perform tail-call elimination -- to implement procedure calls in such a way that, if the last step in procedure A is a call to procedure B (so that A will simply return to its caller whatever value is returned by B), the memory resources supporting the call to A can be freed and recycled as soon as the call to B has been started. To make this possible, the implementer arranges for B to return its value directly to A's caller, bypassing A entirely. In particular, this technique is required to work when A and B are the same procedure, invoking itself recursively (in which case the recursion is called tail recursion), and even if there are a number of recursive calls, each of which will return to its predecessor the value returned by its successor. In the implementation, each of the intermediate calls vanishes as soon as its successor is launched.

However, this clever technique, which speeds up procedure calling and enables Scheme to use memory very efficiently, is guaranteed to work only if the procedure call is the last step. It may not work on the following procedure, for instance:

(define sum-of-list
  (lambda (ls)
    (if (null? ls)
        0
        (+ (car ls) (sum-of-list (cdr ls))))))

The recursive call in this case is not a tail call, since after it returns its value the first number on the list still has to be added to that value.

However, it is possible to write a tail-recursive version of sum-of-list:

(define sum-of-list
  (lambda (ls)
    (sum-of-list-kernel ls 0)))

(define sum-of-list-kernel
  (lambda (ls running-total)
    (if (null? ls)
        running-total
        (sum-of-list-kernel (cdr ls) (+ (car ls) running-total)))))

The idea is to provide, in each recursive call, a second argument, giving the sum of all the list elements that have been encountered so far: the running total of the previously encountered elements. When the end of the list is reached, the value of this running total is returned; until then, each recursive call strips one element from the beginning of the list, adds it to the running total, and finally calls itself recursively with the shortened list and the augmented running total. The ``finally'' part is important: sum-of-list-kernel is tail-recursive.

Instead of writing two separate procedures, the authors of our textbook would typically do this with a letrec-expression:

(define sum-of-list
  (lambda (ls)
    (letrec ((kernel (lambda (rest running-total)
                       (if (null? rest)
                           running-total
                           (kernel (cdr rest)
                                   (+ (car rest) running-total))))))
      (kernel ls 0))))

A contemporary Scheme programmer, however, would probably use yet another variation of the let-expression to define this procedure -- the so-called ``named let''-expression.

The named let has the same syntax as a regular let-expression, except that there is a symbol between the keyword let and the binding list. The named let binds this extra symbol to a procedure whose parameters are the same as the variables in the binding list and whose body is the same as the body of the let-expression. So, for example, the named let-expression

(let kernel ((rest ls)
             (running-total 0))
  (if (null? rest)
      running-total
      (kernel (cdr rest)
              (+ (car rest) running-total))))

does exactly the same thing as the letrec-expression in the previous example: It binds the variable kernel to a procedure that takes two parameters, rest and running-total, with the if-expression as its body. Then it binds rest to the value of ls and running-total to 0 and starts evaluating the if-expression. When it comes to the call to kernel, it invokes the procedure to which kernel is bound, just as if that procedure had been introduced by letrec.

Anything that the named let can do, letrec can also do, which is one reason why our textbook does not use the named let. But many Scheme programmers find named let-expressions more readable than the corresponding letrec-expressions, which seem kind of top-heavy.

Here are a couple of examples from the lab on local binding and recursion, showing how to use named let-expressions for husk-and-kernel definitions generally.

;; The IOTA procedure takes any non-negative integer UPPER-BOUND as
;; argument and returns a list of the non-negative integers strictly less
;; than UPPER-BOUND, in ascending order.

(define iota
  (lambda (upper-bound)
    (let kernel ((so-far 0))
      (if (= so-far upper-bound)
          '()
          (cons so-far (kernel (+ so-far 1)))))))

;; The DUPL procedure takes two arguments, a string STR and a non-negative
;; integer FACTOR, and returns a string consisting of FACTOR successive
;; copies of STR.

(define dupl
  (lambda (str factor)
    (if (not (string? str))
        (error 'dupl "the first argument must be a string"))
    (if (or (not (integer? factor))
            (negative? factor))
        (error 'dupl
               "the second argument must be a non-negative integer"))
    (let kernel ((remaining factor)
                 (accumulator ""))
      (if (zero? remaining)
          accumulator
          (kernel (- remaining 1)
                  (string-append accumulator str))))))

Exercise 1

Of the definitions just given for iota and dupl, one is tail-recursive and the other is not.


Exercise 2

Rewrite the longest-on-list procedure, from the first lab on recursion, so that it yields the same result but is tail-recursive.


Exercise 3

If your solution to exercise 2 does not use a named let-expression, rewrite it so that it does.


As another example, let's consider exercise 3.5 from the textbook:

Define a procedure index that has two arguments, an item a and a list of items ls, and returns the index of a in ls, that is, the zero-based location of a in ls. If the item is not in the list, the procedure returns -1. Test your procedure on:

  (index 3 '(1 2 3 4 5 6)) ===> 2
  (index 'so '(do re mi fa so la ti do)) ===> 4
  (index 'a '(b c d e)) ===> -1
  (index 'cat '()) ===> -1

The idea is to work down through the elements of the list, keeping track of how many of them we pass. If we reach the end of the list, we return -1; if we encounter the item that we are looking for, we return the number of bypassed items; otherwise, we continue down the list, adding 1 to the number of bypassed items:

(define index
  (lambda (a ls)
    (let loop ((rest ls)
               (bypassed 0))
      (cond ((null? rest) -1)
            ((equal? (car rest) a) bypassed)
            (else (loop (cdr rest) (+ bypassed 1)))))))

Exercise 4

Write and test a procedure display-countdown that takes one argument, a non-negative integer, and uses display and newline to print out the positive integers equal to or less than its argument, in descending order, one per line. As its value, it should return the string "Blast off!". Use a named let-expression and make the procedure tail-recursive.


Exercise 5

Write a tail-recursive version of the tally-by-parity procedure, which takes any list ls of integers and returns a two-element list in which the first element is the number of odd integers in ls and the second is the number of even integers in ls:

> (tally-by-parity '(2 3 5 7 11 13))
(5 1)
> (tally-by-parity '(0 1 2 3 4 5 6)) so thgatthat plays one round
of jeu-vain, returning #t if the player wins and
#f if she loses
(3 4)
> (tally-by-parity '(-8 124 0 124))
(0 4)
> (tally-by-parity '())
(0 0)

Hint: Pack the recursion into a kernel using a named let-expression. Create local bindings for the number of odd integers and the number of even integers so far encountered.


Exercise 6

Consider your number-of-heads procedure from exercise 6 of the lab on simulation using random numbers. If your previous procedure was not tail-recursive, rewrite it so that the new version is tail-recursive.


This document is available on the World Wide Web as

http://www.math.grin.edu/~walker/courses/151.fa98/tail-call-elimination.html

created March 2, 1997 by John David Stone
last revised November 8, 1998 by Henry M. Walker

Henry M. Walker (walker@math.grin.edu)