PL: Lecture #3  Tuesday, September 22nd

Tail calls

You should generally know what tail calls are, but here’s a quick review of the subject. A function call is said to be in tail position if there is no context to “remember” when you’re calling it. Very roughly, this means that function calls that are not nested in argument expressions of another call are tail calls. This definition is something that depends on a context, for example, in an expression like

(if (huh?)
  (foo (add1 (* x 3)))
  (foo (/ x 2)))

both calls to foo are tail calls, but they’re tail calls of this expression and therefore apply to this context. It might be that this code is inside another call, as in

(blah (if (huh?)
        (foo (add1 (* x 3)))
        (foo (/ x 2)))

and the foo calls are now not in tail position. The main feature of all Scheme implementations including Racket wrt tail calls is that calls that are in tail position of a function are said to be “eliminated”. That means that if we’re in an f function, and we’re about to call g in tail position and therefore whatever g returns would be the result of f too, then when Racket does the call to g it doesn’t bother keeping the f context — it won’t remember that it needs to “return” to f and will instead return straight to its caller. In other words, when you think about a conventional implementation of function calls as frames on a stack, Racket will get rid of a stack frame when it can.

Another way to see this is to use DrRacket’s stepper to step through a function call. The stepper is generally an alternative debugger, where instead of visualizing stack frames it assembles an expression that represents these frames. Now, in the case of tail calls, there is no room in such a representation to keep the call — and the thing is that in Racket that’s perfectly fine since these calls are not kept on the call stack.

Note that there are several names for this feature:

When should you use tail calls?

Often, people who are aware of tail calls will try to use them always. That’s not always a good idea. You should generally be aware of the tradeoffs when you consider what style to use. The main thing to remember is that tail-call elimination is a property that helps reducing space use (stack space) — often reducing it from linear space to constant space. This can obviously make things faster, but usually the speedup is just a constant factor since you need to do the same number of iterations anyway, so you just reduce the time spent on space allocation.

Here is one such example that we’ve seen:

(define (list-length-1 list)
  (if (null? list)
    (+ 1 (list-length-1 (rest list)))))

;; versus

(define (list-length-helper list len)
  (if (null? list)
    (list-length-helper (rest list) (+ len 1))))
(define (list-length-2 list)
  (list-length-helper list 0))

In this case the first (recursive) version version consumes space linear to the length of the list, whereas the second version needs only constant space. But if you consider only the asymptotic runtime, they are both O(length(l)).

A second example is a simple implementation of map:

(define (map-1 f l)
  (if (null? l) l (cons (f (first l)) (map-1 f (rest l)))))

;; versus

(define (map-helper f l acc)
  (if (null? l)
    (reverse acc)
    (map-helper f (rest l) (cons (f (first l)) acc))))
(define (map-2 f l)
  (map-helper f l '()))

In this case, both the asymptotic space and the runtime consumption are the same. In the recursive case we have a constant factor for the stack space, and in the iterative one (the tail-call version) we also have a similar factor for accumulating the reversed list. In this case, it is probably better to keep the first version since the code is simpler. In fact, Racket’s stack space management can make the first version run faster than the second — so optimizing it into the second version is useless.

Note on Types

Types can become interesting when dealing with higher-order functions. For example, map receives a function and a list of some type, and applies the function over this list to accumulate its output, so its type is:

;; map : (A -> B) (Listof A) -> (Listof B)

Actually, map can use more than a single list, it will apply the function on the first element in all lists, then the second and so on. So the type of map with two lists can be described as:

;; map : (A B -> C) (Listof A) (Listof B) -> (Listof C)

Here’s a hairy example — what is the type of this function:

(define (foo x y)
  (map map x y))

Begin by what we know — both maps, call them map1 and map2, have the double- and single-list types of map respectively, here they are, with different names for types:

;; the first `map', consumes a function and two lists
map1 : (A B -> C) (Listof A) (Listof B) -> (Listof C)

;; the second `map', consumes a function and one list
map2 : (X -> Y) (Listof X) -> (Listof Y)

Now, we know that map2 is the first argument to map1, so the type of map1s first argument should be the type of map2:

(A B -> C) = (X -> Y) (Listof X) -> (Listof Y)

From here we can conclude that

A = (X -> Y)

B = (Listof X)

C = (Listof Y)

If we use these equations in map1’s type, we get:

map1 : ((X -> Y) (Listof X) -> (Listof Y))
      (Listof (X -> Y))
      (Listof (Listof X))
      -> (Listof (Listof Y))

Now, foo’s two arguments are the 2nd and 3rd arguments of map1, and its result is map1s result, so we can now write the type of foo:

;; foo : (Listof (X -> Y))
;;      (Listof (Listof X))
;;      -> (Listof (Listof Y))
(define (foo x y)
  (map map x y))

This should help you understand why, for example, this will cause a type error:

(foo (list add1 sub1 add1) (list 1 2 3))

and why this is valid:

(foo (list add1 sub1 add1) (map list (list 1 2 3)))

Side-note: Names are important

An important “discovery” in computer science is that we don’t need names for every intermediate sub-expression — for example, in almost any language we can write the equivalent of:

s = (-b + sqrt(b^2 - 4*a*c)) / (2*a)

instead of

x = b * b
y = 4 * a
y = y * c
x = x - y
x = sqrt(x)
y = -b
x = y + x
y = 2 * a
s = x / y

Such languages are put in contrast to assembly languages, and were all put under the generic label of “high level languages”.

(Here’s an interesting idea — why not do the same for function values?)