Work in pairs.
Switch roles often.
This lab uses features not included in the student languages. Add these two lines at the top of your file to include them:
(require racket) (require (only-in racket/control abort))
The purpose of this lab is to introduce you to the control operator call-with-current-continuation
, or call/cc
for short. Control operators allow the programmer to
manipulate the evaluation of a program as it executes—many consider them
difficult to understand and tricky to use! To try to overcome this difficulty,
we will spend the majority of the lab discussing the notion of "control" and
getting comfortable with the control operators abort
and call/cc
. At the end of the lab we will apply call/cc
to implement exception handling in Racket.
Consider the following dumb computation:
(product (list 5 8 1 0 2 9 3)) = (* 5 (product (list 8 1 0 2 9 3))) = (* 5 (* 8 (product (list 1 0 2 9 3)))) = (* 5 (* 8 (* 1 (product (list 0 2 9 3))))) = (* 5 (* 8 (* 1 (* 0 (product (list 2 9 3)))))) = (* 5 (* 8 (* 1 (* 0 (* 2 (product (list 9 3))))))) = (* 5 (* 8 (* 1 (* 0 (* 2 (* 9 (product (list 3)))))))) = (* 5 (* 8 (* 1 (* 0 (* 2 (* 9 (* 3 (product empty)))))))) = (* 5 (* 8 (* 1 (* 0 (* 2 (* 9 (* 3 1))))))) = (* 5 (* 8 (* 1 (* 0 (* 2 (* 9 3)))))) = (* 5 (* 8 (* 1 (* 0 (* 2 27))))) = (* 5 (* 8 (* 1 (* 0 54)))) = (* 5 (* 8 (* 1 0))) = (* 5 (* 8 0)) = (* 5 0) = 0
After product
sees 0
it continues to
compute (product (list 2 9
3))
, even though it could predict the result of this part of the
computation:
(* 0 (product (list 2 9 3))) = 0
We could avoid some work in this case by skipping the rest of the computation if
an element in the list is 0
. To do this, we could
refine the implementation from this…
(define (product ns) (cond [(empty? ns) 1] [else (* (first ns) (product (rest ns)))]))
…to this:
(define (product ns) (cond [(empty? ns) 1] [(= 0 (first ns)) 0] [else (* (first ns) (product (rest ns)))]))
Now the computation is less dumb since it avoids computing (product (list 2 9 3))
:
(product (list 5 8 1 0 2 9 3)) = (* 5 (product (list 8 1 0 2 9 3))) = (* 5 (* 8 (product (list 1 0 2 9 3)))) = (* 5 (* 8 (* 1 (product (list 0 2 9 3))))) = (* 5 (* 8 (* 1 0))) = (* 5 (* 8 0)) = (* 5 0) = 0
But wouldn't it be nice if it also avoided computing every step of (*
5 (* 8 (* 1
0)))
and instead replaced the whole thing with 0
?
(product (list 5 8 1 0 2 9 3)) = (* 5 (product (list 8 1 0 2 9 3))) = (* 5 (* 8 (product (list 1 0 2 9 3)))) = (* 5 (* 8 (* 1 (product (list 0 2 9 3))))) = (* 5 (* 8 (* 1 (STOP!-THE-ANSWER-IS 0)))) = 0
If we had an operator like STOP!-THE-ANSWER-IS
then we could refine
our implementation of product
once more to avoid computing any of
the multiplications by 0
:
(define (product ns) (cond [(empty? ns) 1] [(= 0 (first ns)) (STOP!-THE-ANSWER-IS 0)] [else (* (first ns) (product (rest ns)))]))
But how can we get an operator like STOP!-THE-ANSWER-IS
? The
closest thing we have is error
, but it signals an
error to the user whereas we just want to return a normal value.
The problem is that Racket has a strict idea about how a program is evaluated, which prevents us from defining an operator like STOP!-THE-ANSWER-IS
. That is, Racket has a strict notion of the control of a program. What we lack
is a way to influence this control.
Here's an easy way out of our problem: I will simply give you
STOP!-THE-ANSWER-IS
, and I will call it abort
. Since it manipulates the control of the
remainder of the program's evaluation we'll call it a control
operator.
The operator abort
behaves just as we
wanted above: Racket will evaluate your program as normal, except if it ever
needs to evaluate a part that says (abort
val)
, then Racket will replace the whole program with val
.
Consider some examples:
Plan to do some arithmetic but decide partway through that you're finished:
(* 2 (+ 1 (abort (* 5 (- 6 2))))) = (* 2 (+ 1 (abort (* 5 4)))) = (* 2 (+ 1 (abort 20))) = 20
Notice that abort
has no effect until its
argument evaluates to a value.
Choose one of two functions to apply while building a string:
(string-append "Your total is: $" ((first (list (λ (n) (abort "Hi Mom!")) number->string)) 80)) = (string-append "Your total is: $" ((λ (n) (abort "Hi Mom!")) 80)) = (string-append "Your total is: $" (abort "Hi Mom!")) = "Hi Mom!"
Even if abort
's argument doesn't need to
evaluate, it still has no effect until control reaches it.
Choose the other function instead:
(string-append "Your total is: $" ((second (list (λ (n) (abort "Hi Mom!")) number->string)) 80)) = (string-append "Your total is: $" (number->string 80)) = (string-append "Your total is: $" "80") = "Your total is: $80"
Since we never needed to evaluate (abort "Hi Mom!")
, control of the program proceeded normally.
Apply each function in a list to the number 3
:
(map (λ (f) (f 3)) (list add1 sqr number->string abort odd?)) = (cons (add1 3) (map (λ (f) (f 3)) (list sqr number->string abort odd?))) = (cons 4 (map (λ (f) (f 3)) (list sqr number->string abort odd?))) = (cons 4 (cons (sqr 3) (map (λ (f) (f 3)) (list number->string abort odd?)))) = (cons 4 (cons 9 (map (λ (f) (f 3)) (list number->string abort odd?)))) = (cons 4 (cons 9 (cons (number->string 3) (map (λ (f) (f 3)) (list abort odd?))))) = (cons 4 (cons 9 (cons "3" (map (λ (f) (f 3)) (list abort odd?))))) = (cons 4 (cons 9 (cons "3" (cons (abort 3) (map (λ (f) (f 3)) (list odd?)))))) = 3
Here we see that we can pass abort
around
just like any other function.
Exercise 1
What do each of the following programs evaluate to? Work them out in your head first, and then use DrRacket to check your understanding.
(+ 1 (abort 2)) (+ 1 (abort ((λ (x y) (+ (add1 x) (sub1 y))) 2 3))) (+ 1 (abort (λ (x y) (+ (add1 x) (sub1 y))))) (local [(define (loop x) (loop x))] (loop (abort "Hello, World!"))) (local [(define (loop x) (loop x))] (abort (loop "Hello, World!"))) abort (abort abort) (map abort (list 1 2 3)) (map (λ (x) x) (list add1 abort (λ (x) (+ x x))))
Exercise 2
What should the contract for abort
be?
Recall our original problem: we wanted to implement product
so that
it short-circuited if it encountered a 0
.
Using abort
, now we can:
(define (product ns) (cond [(empty? ns) 1] [(= 0 (first ns)) (abort 0)] [else (* (first ns) (product (rest ns)))]))
This appears to solve our problem, but what if product
is part of
some larger computation?
(+ 1 (product (list 5 8 1 0 2 9 3))) = (+ 1 (* 5 (* 8 (* 1 (abort 0))))) = 0
The computation waiting for product
to finish is lost and replaced
with 0
… What if this computation is computing
the new balance in my bank account and product
is only supposed to
compute the amount to withdraw?
(- old-balance (product (list 5 8 1 0 2 9 3))) = (- old-balance (* 5 (* 8 (* 1 (abort 0))))) = 0
You might be impressed with your faster implementation of product
,
but I certainly won't be impressed with my new balance…
Aborting a computation is somewhat handy, but it's too global—it affects the entire computation. What we want is something that enables us to manipulate control more locally so that we can grab control at one point in the program and then jump back to it sometime later.
In our example
(- old-balance (product (list 5 8 1 0 2 9 3)))
what we want is a way to grab the remainder of the computation, (-
old-balance •)
, before we start to compute (product (list 5 8 1 0 2 9 3))
. This
way, when we see the 0
we don't abort the
whole computation with 0
, but
instead we only abort the computation between 0
and (- old-balance •)
.
(- old-balance (product (list 5 8 1 0 2 9 3))) = (- old-balance (WHERE-AM-I? (λ (GO-BACK) (product-helper GO-BACK (list 5 8 1 0 2 9 3))))) ; GO-BACK = (λ (x) (abort (- old-balance x))) = (- old-balance (product-helper GO-BACK (list 5 8 1 0 2 9 3))) = (- old-balance (* 5 (product-helper GO-BACK (list 8 1 0 2 9 3)))) = (- old-balance (* 5 (* 8 (product-helper GO-BACK (list 1 0 2 9 3))))) = (- old-balance (* 5 (* 8 (* 1 (product-helper GO-BACK (list 0 2 9 3)))))) = (- old-balance (* 5 (* 8 (* 1 (GO-BACK 0))))) = (- old-balance (* 5 (* 8 (* 1 ((λ (x) (abort (- old-balance x))) 0))))) = (- old-balance (* 5 (* 8 (* 1 (abort (- old-balance 0)))))) = (- old-balance 0) = old-balance
Here we're pretending that we have an operator WHERE-AM-I?
that
grabs the rest of the computation (- old-balance •)
, adds an
abort
, turns it into a λ
, and then passes it into our own function so that
GO-BACK = (λ (x) (abort
(- old-balance x)))
. We then carry GO-BACK
along as we
compute the product so that when we encounter 0
we can call (GO-BACK 0)
to locally abort the product computation
and jump straight to (- old-balance 0)
.
Exercise 3
Yikes! … Got that?
Partners, take turns explaining to each other how WHERE-AM-I?
behaves in the above example.
Exercise 4
What would happen if GO-BACK = (λ (x) (abort (- old-balance x)))
was just (λ (x) (- old-balance x))
, without the abort
?
Exercise 5
Assuming the operator WHERE-AM-I
?, what is the implementation of
product
and product-helper
that behaves as above?
Enough pretending—I'll give you this control operator too.
How does it behave? It grabs the rest of the computation, turns it into an
aborting λ
, and hands it to the function
you supply. Your function can then do whatever it wants with it. If it calls it
with argument val
, the computation will jump back to the captured
point, filling the hole with val
. If it doesn't call it, life
proceeds as usual.
If we call the rest of the computation at some point in the program the current
continuation, then a good name for this operator is call-with-current-continuation
. That's a mouthful, so
let's also abbreviate it as call/cc
. Since
we're about to start carrying around lots of continuations we should pick a good
name for them: k
is conventional, just like n
for
integers or s
for strings.
Exercise 6
What do each of the following programs evaluate to? Work them out in your head first, and then use DrRacket to check your understanding.
Your first step in each should be to figure out what k
is, the
continuation that's current when call/cc
is invoked.
(+ 1 (call/cc (λ (k) (k 3)))) (+ 1 (call/cc (λ (k) (* 2 (k 3))))) (+ 1 (call/cc (λ (k) (* 2 3)))) (call/cc (λ (k) (* 2 (k 3)))) ((call/cc (λ (k) +)) 2 3) ((call/cc (λ (k) (k +))) 2 3) ((call/cc (λ (k) (+ 1 (k +)))) 2 3)
Exercise 7
What should the contract for call/cc
be?
Now that you have a feel for call/cc
and
abort
, let me show you a very concise and
very precise way to describe their behavior. Think of these like laws of
physics—very small, very dense, and containing all the information you
need. Much staring required.
To illustrate what a law looks like in Racket, let's first look at a law that
describes behavior we're already comfortable with: the law of λ
. It says that if you apply a λ
to a value then it plugs that value into its body:
((λ (x) exp) val) =
replacex
withval
inexp
It's important that val
is a value: if we apply
(λ (x) exp)
to some expression
exp2
that isn't yet evaluated, then ((λ (x) exp) exp2)
must first evaluate exp2
to a value val
before it can apply the above law.
So a law is just an equation between two expressions that we read left to right:
when you see the thing on the left, replace it with the thing on the right. What
then should be the laws for call/cc
and abort
?
(abort val) =
…
(call/cc val) =
…
Whereas the law of λ
didn't care where in
the program the application occurred, laws for control operators need to know
their context because it determines their current continuation. What we
want is something like
C[(abort val)] =
…
C[(call/cc val)] =
…
where the context C
represents a program with a hole in it, and
C[exp]
is the same with exp
plugged in the hole. Given
this, the law for abort
now seems clear: throw away the
context and replace the whole program with the given value.
C[(abort val)] = val
But this law gets us in trouble. It says that this computation should happen:
(if (even? 0) 42 (+ 1 (abort -1))) = -1
What happened? C = (if (even? 0) 42 (+ 1
•))
is certainly a legal context… The problem is that our
law evaluates (abort val)
anywhere in the program at any time, but we meant for it to
evaluate (abort val)
only when it's the
current expression being evaluated. What we wanted was:
(if (even? 0) 42 (+ 1 (abort -1))) = (if true 42 (+ 1 (abort -1))) = 42
We want the above law to apply only when (abort
val)
is the current expression to evaluate. So let's only talk
about contexts where the hole is the current expression. We will call these the
evaluation contexts and write them as E
. Our laws now look
like this:
E[(abort val)] = val
E[(call/cc val)] =
…
This properly captures the behavior of abort
: if you
encounter (abort val)
while evaluating
a program, throw away the rest of the program and replace all of
E[(abort val)]
with just val
.
This rules out the bad computation above and enables the good one.
What about the law for call/cc
?
E[(call/cc val)] =
…
Above we said that (call/cc val)
grabs its
current continuation, wraps it in an aborting λ
, and
hands it to val
. Since the current continuation is exactly the
evaluation context E
, this is now straightforward:
E[(call/cc val)] = E[(val (λ (x) (abort E[x])))]
That's it—that's the crux of the lab in two laws of Racket:
E[(abort val)] = val E[(call/cc val)] = E[(val (λ (x) (abort E[x])))]
All we lacked before was the notion of an evaluation context.
Exercise 8
What do each of the following programs evaluate to? Work them out in your head first, and then use DrRacket to check your understanding.
These are more challenging than the last set. As before, start by figuring out
all the relevant continuations—if you get stuck, step the program in your
head (or on paper) to find the evaluation context when (call/cc val)
becomes the current expression.
(+ 1 (call/cc (λ (k) (* 2 (list (k 3) (k 4) (k 5)))))) (+ 1 (call/cc (λ (k) (* 2 (map k (list 3 4 5)))))) (cons 1 (list (call/cc (λ (k) (* 2 (k 3)))) (call/cc (λ (k) (* 4 (k 5)))))) (cons 1 (map call/cc (list (λ (k) (* 2 (k 3))) (λ (k) (* 3 (k 5)))))) (local [(define l (call/cc (λ (k) (list k 0 1))))] (local [(define f (first l)) (define a (second l)) (define b (third l))] (if (< a 7) (f (list f (+ 1 a) (* 2 b))) b))) (call/cc (λ (k) k)) (call/cc abort) (call/cc call/cc) ((call/cc call/cc) (call/cc call/cc))
Besides creating puzzling programs, what are control operators good for? If you've programmed in other languages, you might have seen "control-like" constructs such as:
break
statements in loopscontinue
statements in loopsreturn
statements in procedurestry
/catch
blocks for handling exceptionsOr maybe:
And I should mention a couple of useful constructs that most programmers probably haven't seen since most languages don't provide support for them:
Whereas most languages provide these kinds of features for you (or not), if you
have a control operator like call/cc
you
can build them yourself. Let's finish up the lab by using call/cc
to build a try-catch
operator to handle exceptions in Racket.
Recall the error
operator:
> (cons step (error "I'm a doctor, not an escalator!")) I'm a doctor, not an escalator!
Calling error
lets you signal errors to
the user. A useful variant would be a try-catch
operator that lets you signal errors to other parts of the program:
(try-catch (λ () (sqrt (safe-/ 2 (safe-first l)))) (λ (err) (cond [(symbol=? err 'div-0) ...] [(symbol=? err 'empty-list) ...]))) (define (safe-/ a b) (if (= b 0) (throw 'div-0) (/ a b))) (define (safe-first l) (if (empty? l) (throw 'empty-list) (first l)))
Here's how we would like this to work. To evaluate the program (try-catch thunk
handle)
, the thunk—a function with no arguments—is
invoked, (thunk)
, and if any part of the program calls throw
while (thunk)
is evaluating, then
the evaluation of (thunk)
is abandoned and (handle
err)
is evaluated instead, where err
is the argument
supplied to throw
. Calling throw
is often called throwing or
raising an exception, and the argument handle
is often
called the exception handler. If no exception is raised, then
(try-catch thunk handle)
evaluates to the
result of (thunk)
; if the exception err
is raised,
then it evaluates to the result of (handle err)
.
Exercise 9
What should each of the following programs do? (You'll have to work them out in your head, since try-catch
and throw
aren't written yet!)
(+ 1 (try-catch (λ () (* 2 (throw 3))) (λ (err) (- err)))) (+ 1 (try-catch (λ () (* 2 3)) (λ (err) (- err)))) (+ 1 (try-catch (λ () (* 2 (throw 3))) (λ (err) (throw (- err))))) (+ (try-catch (λ () (* 2 (throw 3))) (λ (err) (- err))) (try-catch (λ () (string-append (throw "fish") "cows")) (λ (err) (string-length err)))) (throw "foo") (+ (try-catch (λ () 1) (λ (err) err)) (throw "foo"))
Exercise 10
What should the contracts be for throw
and try-catch
? Assume a type
Error
for the inputs to throw
and the handler. (There's no reason to expect
it to always be a String
or Number
, as above.)
Now let's implement try-catch
and throw
. For throw
to
abort the evaluation of (thunk)
and jump to the handler, try-catch
will have to do some fancy footwork with
call/cc
. But in addition to that, notice
that (throw err)
has no idea which
handler it should call!—we need some way for try-catch
to communicate the current handler to
throw
. A good solution for this is to use
state…
Exercise 11
Using call/cc
and set!
, implement try-catch
and throw
as described above. Formulate the examples from
Exercise 9 as tests for your implementation. (You might find check-error
useful for the last two.)
Make sure you're in Advanced Student Language to use set!
. Refer to the help
desk and sections 35 and 38 in the
book for more information about programming with state and using set!.
Exercise 12
Consider a program with nested try-catch
expressions:
(- 1 (try-catch (λ () (* 2 (try-catch (λ () (+ 3 (throw 4))) (λ (err) (- (throw (+ 5 err))))))) (λ (err) (sqrt err))))
The description above doesn't specify how nested expressions should
behave… The least surprising behavior would be for a throw
to jump to the handler of the try-catch
expression encountered most recently, but
only for try-catch
expressions that we're still inside
of. Thus, the above program should throw twice and evaluate to (- 1 (sqrt (+ 5
4))) = -2
.
Formulate additional tests involving nested try-catch
expressions and refine your implementation, if necessary, to handle them.
Exercise 13 (challenge)
Research coroutines and implement them using call/cc
. A
quick google
search should get you started… (But watch out for spoilers!)