This page summarizes CPS and gives an introduction to continuations.
A function call is a tail-call (or, is in tail-position) in an expression if the value returned from the call is the value of the entire expression. Examples:
(f 4) is not a tail-call in (+ (f 4) 3)
The call to f in (f (+ 4 3)) is a tail-call
(g 6) is not a tail-call in (f (g 6)), but the call to f is a tail-call
(f 6) is a tail-call in
(cond [(= n 0) 1] [else (f 6)])because (f 6) is the value of the entire expression if it is executed.
Tail calls matter to us for two reasons:
When we discussed the web assignment, we said that the difference between the textual I/O version and the web/script version was that the textual version was "tree-like" (see the calls to prompt-read-many nested inside the calls to display-order), while the script version was "linear" (each script function ended with a function call). Tail-calls distinguish "tree-like" from "linear" programs. In a linear program (like web scripts) all calls to user-defined functions must be tail calls.
Thus, to compile tree-like programs into linear programs, we need to be able to identify and remove non-tail calls.
Tail-calls also use stack space more efficiently. To see this, consider the following two versions of sum (over a list of numbers):
(define (sum alon) (cond [(empty? alon) 0] [(cons? alon) (+ (first alon) (sum (rest alon)))])) (define (sum2 alon) (sum-help alon 0)) (define (sum-help alon total) (cond [(empty? alon) total] [(cons? alon) (sum-help (rest alon) (+ (first alon) total))]))
Sum and sum2 return the same answers, but the shape of their computations is somewhat different. Consider the major steps when running each program on the input (list 1 3 5 7):
(sum (list 1 3 5 7)) (+ 1 (sum (list 3 5 7))) (+ 1 (+ 3 (sum (list 5 7)))) (+ 1 (+ 3 (+ 5 (sum (list 7))))) (+ 1 (+ 3 (+ 5 (+ 7 (sum empty))))) (+ 1 (+ 3 (+ 5 (+ 7 0)))) (+ 1 (+ 3 (+ 5 7))) (+ 1 (+ 3 12)) (+ 1 15) 16 (sum2 (list 1 3 5 7)) (sum-help (list 1 3 5 7) 0) (sum-help (list 3 5 7) 1) (sum-help (list 5 7) 4) (sum-help (list 7) 9) (sum-help empty 16) 16
Notice how the sum version builds up a list of pending computations waiting for the successive results of sum, while no computations are waiting for the results of the calls to sum-help. In other words, the calls to sum build up in an arrow-like shape; the sum-help calls, in contrast, all line-up underneath each other).
Pending computations have to be stored somewhere (they are stored on the stack). Thus, the sum version uses stack storage (plus some extra time to manage the stack storage) that the sum-help version doesn't need. If we ran these two programs on a long-enough list, we could run out of memory using the sum version, while not running out of memory with the sum-help version.
So, we should care about tail-calls for two reasons: they capture the structure of web programs (we can't write scripts without them) and they can lead to space efficiency if a language exploits them.
Fortunately, we can convert non-tail calls into tail calls through a fairly straightforward method. We'll demo the method first, then summarize the steps. Note that this method preserves the behavior of the original program (as well it should!).
Consider the following definition and expression:
(define (f x) (+ x 3)) (* (f 5) 2)
The call (f 5) is not a tail-call. If we wanted it to be a tail-call, we'd have to move it outside of the multiplication. Doing so, though, would leave a hole in the multiplication expression, as follows:
(* hole 2)
Having the unbound variable "hole" in the expression is bad though, since unbound variables lead to errors. We can fix this problem by adding (lambda (hole) ...) to the expression:
(lambda (hole) (* hole 2))
Now return to the call to f that we yanked out. To put the call in tail position, we can't embed it in another expression. So, the call to f must be at the top level:
(f 5)
This isn't quite right though, because this expression would return 8 where the original expression returned 16. We need to send the return value from this call to our new (lambda (hole) (* hole 2)) expression, but while leaving the call to f at the top level. We can do this by passing the lambda expression to f as an additional argument and having f send its result to the lambda expression, as follows. To avoid confusion, I'll rename f to f/k.
(define (f/k x k) (k (+ x 3))) (f/k 5 (lambda (hole) (* hole 2)))
The new call to f/k returns the same answer as the original call to f, but note that the call to f/k is in tail-position, while the call to f is not.
This new (lambda (hole) ...) expression captures the pending computation for the call to f. Pending computation functions are called continuations (because they show how to "continue" the computation). Conventionally, texts that teach you to pass continuations as arguments use "k" for the continuation argument. It's just a convention though (and one you can't always follow, because you can have multiple continuations in the same scope).
To convert a non-tail call to function f in expression E to a tail-call:
Example: (define (f x) (+ x 3)) (* (f 5) 2)
Copy the original call to f to the top-level (ie, not nested inside any other expression)
(f 5)
Replace the entire call to f in E with a new variable.
(* hole 2)
Enclose the revised E in a lambda expression that has the new variable as a parameter. For example, assuming the variable used in the previous step was named "hole", wrap the revised E in (lambda (hole) ...).
(lambda (hole) (* hole 2))
Create a new version of f (here called f/k) that takes an additional argument (contract: a function of one argument) and calls that function on the original body of f.
(define (f/k x k) (k (+ x 3)))
Modify the copied call to f to call f/k, passing the (lambda (hole) ...) function as the last argument [assuming you haven't already done this -- a function never needs more than one continuation argument].
(f/k 5 (lambda (hole) (* hole 2)))
Make sure that all calls in the body of f/k are tail calls, and repeat this procedure as needed to convert them into tail calls.
Try converting the following expressions using the above method:
(define (g x) (* x 6)) (define (h y) (+ y 5)) (+ 4 (g (h 3))) [hint: convert to using h/k and g first, then to h/k and g/k]
(define (foo y) (+ (h (* y 2)) 7)) [h from above] (foo 8)
(define (sum-area w1 h1 w2 h2) (+ (rect-area w1 h1) (rect-area w2 h2))) (sum-area 2 4 6 8)
The f/k version doesn't seem more efficient than the original in the example above, so what was the point?
The example shown here doesn't gain in efficiency, but it does become a linear (as opposed to "tree-like" program). To put this in perspective, assume that f were really a CGI script (called f-script). You couldn't use f-script in a program as
(* (f-script 5) 2)
because scripts can't "return" to previous computations. They can only call other scripts or terminate (ie, all calls to scripts must be tail calls). If you wanted to use f-script, you'd have to put the call to it in tail position. For example:
(define (f/k-script x k-script) (k-script (+ x 3))) (f/k-script 5 (lambda (hole) (* hole 2)))
This version now has valid calls to scripts, because every script is called in tail-position. If having a script (k-script) as a parameter bothers you, ask yourself what a GET or POST method in an html form does: it specifies the name of the next script to call. We're simply passing that script as a parameter, rather than hardwiring it into the code. A more CGI-looking version of these calls might appear as:
(define (f/k-script x) (k-script (+ x 3))) (define (k-script hole) (* hole 2)) (f/k-script 5)
which now looks remarkably like a series of web scripts.
So will we ever see an example where converted version is more efficient than the original?
Yes. If you were to apply the steps above to the sum program, you'd get something with the same shape as the sum-help program. The example of these two programs earlier in these notes shows why sum-help is more space-efficient than sum.
In f/k above, the call to + is not a tail-call. Isn't that a problem?
No. We are only concerned with converting calls to user-defined functions into tail-calls. Primitive and built-in functions may occur in non-tail position. If this bothers you, consider the script analogy: we define the scripts, so those functions need to be in tail position. We can still use built-in operators in scripts without having them be in tail position though.
Where did this "k" come from?
"k" is just a conventional name for an argument that represents a continuation. As with all parameters, you can name it whatever you want.
This page maintained by Kathi Fisler Department of Computer Science Worcester Polytechnic Institute |