Growing the Language: Let's begin by growing the simply-typed lambda calculus in fairly benign ways. We'll start by adding support for unit and void, products and sums, as well as things like integer operations (e.g t ::= unit | void | t1 -> t2 | t1 * t2 | t1 + t2 v ::= x | () | \x:t.e | (v1,v2) | inl_t v | inr_t v e ::= v | e1 e2 | (e1, e2) | #1 e | #2 e | inl_t e | inr_t e | case e of inl(x) => e1 | inr(x) => e2 with (CBV) evaluation rules as follows: (\x:t.e) v -> e[v/x] #1 (v1,v2) -> v1 #2 (v1,v2) -> v2 case inl_t(v) of inl(x) => e1 | inr(x) => e2 -> e1[v/x] case inr_t(v) of inl(x) => e1 | inr(x) => e2 -> e2[v/x] plus the rules to evaluate left-most inner most expressions (outside of a lambda). The new typing rules are as follows: G |- () : unit G |- e1 : t1 G |- e2 : t2 --------------------------- G |- (e1,e2) : t1 * t2 G |- e : t1 * t2 ---------------- (i = 1,2) G |- #i e : ti G |- e : t1 ---------------------- G |- inl_t2(e) : t1+t2 G |- e : t2 ---------------------- G |- inr_t1(e) : t1+t2 G |- e : t1+t2 G,x1:t1 |- e1 : t G,x2:t2 |- e2 : t ------------------------------------------------------ G |- case e of inl(x1) => e1 | inr(x2) => e2 : t Notice that by stamping the inl and inr terms with the other type in the sum, the rules remain syntax-directed. That is, if we left of the _t2 on inl_t2(e) then we would know that e : t1, but would have to somehow infer t2 from the surrounding context. Nonetheless, in what follows, I will often omit this extra information and just write "inl(e)" or "inr(e)". Notice also that there's a unit introduction rule, but not elimination rule. From a logical perspective, unit corresponds to "true". Dually, there no introduction rule for void which represents the empty type (i.e, "false"). One could imagine introducing a rule such as the following: G1,x:void,G2 |- e : t reflecting that we can never get into a situation where a variable is bound to a value of void type. This corresponds to a proof by contradiction. However, this is only really valid in a CBV interpretation of the language (once we add in non-termination) and there's another way to get at contradiction (or negation) through continuations. The denotational semantics can be extended in a fairly straightforward fashion as well (exercise!). It's also possible to extend the proof that every well-formed expression terminates to this expanded language. Of course, if we add support for recursive functions, this property will break. But let's put that off for a bit and talk about control constructs such as exceptions and continuations... --------------------------------------------------------------------- The language defined so far has no support for exceptions. However, if we want to write a function that is only partially defined, we can always use a sum type. For instance, an integer division operation could be given the type: div : int * int -> (int + unit) with the idea that: div (x,0) = inr_int() div (x,y) = inr_unit(z) where z = z/y Note that any caller of div is going to have to do a pattern match to eliminate the "+" type. This corresponds to the usual situation in C where we return an error code or the real value, except that we use a *disjoint union* to make sure that the values are separate from the error codes. Now suppose we wished to pretend like we had support for exceptions in the language. In particular, suppose we add two new forms of expression: e ::= ... | throw | try e catch e' with the intended semantics that throw transfers control to the nearest (dynamically) enclosing try/catch handler. One way to realize this is to translate the language with throw and try/catch to the language without it using sums. We begin by defining a translation on types: V[unit] = unit V[void] = void V[t1*t2] = V[t1]*V[t2] V[t1+t2] = V[t1]+V[t2] V[t1->t2] = V[t1] -> C[t2] C[t] = V[t] + unit The idea is that values can't raise an exception, but computations (the body of functions) can. When they raise an exception we are really just returning inr() as the result. When a function doesn't raise an exception, we are returning inl(v) as the result, where v is the value of the function. Now we can define an expression translation: E[()] = inl() E[x] = inl(x) E[throw] = inr() E[try e catch e'] = case E[e] of inl(x) => inl(x) | inr(_) => E[e'] E[\x:t.e] = inl(\x:T[t].E[e]) E[e1 e2] = case E[e1] of inl(v1) => (case E[e2] of inl(v2) => v1 v2 | inr(_) => inr()) | inr(_) => inr() E[(e1,e2)] = case E[e1] of inl(v1) => (case E[e2] of inl(v2) => inl(v1,v2) | inr(_) => inr()) | inr(_) => inr() E[#i e] = case E[e] of inl(v1) => #i v1 | inr(_) => inr() and so on. Notice how we've ensured that if e : t, then E[e] : C[t] = V[t] + unit. Of course, writing this translation is somewhat awkward and lets us see the inefficiencies in the resulting code because we're doing all sorts of case analysis at every sub-expression. (Note: we can significantly simplify the translation by first simplifying the code to A-normal form, aka Monadic style, where all non-trivial computations must be bound to a variable. This will isolate the case analysis to essentially the monadic "let" or "bind". More on this later.) Regardless, this is one way to model the control aspects of a language with exceptions: simply translate them to sums. How might we construct a model that more directly represents the intended control aspects? One approach is to introduce the notion of a stack in the abstract machine. First, let's do this in a context without exceptions. Our new abstract machine states will be a pair (S,e) of a stack and an expression. The stack will be a list of frames defined as follows: F ::= [] e | v [] | ([],e) | (v,[]) | #i [] | inl [] | inr [] | case [] of inl(x) => e1 | inr(x) => e2 Each frame has a "hole" denoted by [] in it. I will write F[e] to denote the expression obtained by plugging the hole in the frame F with the expression e. The frames are designed to represent what we should do with the result of evaluating a sub-expression. For instance, when we have a compound term like: e1 e2 we want to first evaluate e1 to a value v1 and *then evaluate v1 e2*. So we represent what we want to do after evaluating e1 by the frame "[] e2". Here are the rewriting rules for our new abstract machine. The key reduction rules are the same and have no effect on the stack: (S, (\x:t.e) v) -> (S, e[v/x]) (S, #i (v1,v2)) -> (S, vi) (S, case inl(v) of inl(x)=>e1 | inr(y)=>e2) -> (S, e1[v/x]) (S, case inr(v) of inl(x)=>e1 | inr(y)=>e2) -> (S, e2[v/x]) The next few rules deconstruct a compound expression by pushing an appropriate frame on the stack: (S, v e) -> ((v [])::S, e) (S, e1 e2) -> (([] e2)::S, e1) (e1 not a value) (S, (v,e)) -> ((v,[])::S, e) (S, (e1,e2)) -> (([],e2)::S, e1) (e1 not a value) (S, #i e) -> ((#i [])::S, e) ... Finally, there is a rule for popping the top frame off the stack: (F::S, v) -> (S, F[v]) The terminal configurations of this abstract machine are of the form (nil, v). Notice that this abstract machine is tail-recursive in the sense that there are no hypotheses for the rewriting rules. That is, the derivations are all flat -- we don't need a stack at the meta-level to implement the langauge because we've refied this aspect in the abstract machine itself. Also notice that contrary to popular belief, a function call does *not* push a frame on the stack. What's the relation between the stack-based machine and our original rules? Given a configuration (S,e) where S = Fn::Fn-1::...::F2::F1::nil, then we write S[e] for the term obtained by F1[F2[...[Fn-1[Fn[e]]]...]]. Then I claim that: (S,e) -> (S',e') implies S[e] -> S'[e'] or S[e] = S'[e'] Now that we've reified stacks in the abstract machine we can start to play around with them. In particular, we can add support for catch/throw as follows: F ::= try [] catch e' (S, try e catch e') -> ((try [] catch e')::S, e) (S1 @ (try [] catch e')::S2, throw) -> (S2, e') (no try/catch in S1) So in one transition step, we pop off all of the frames until we find the nearest enclosing try/catch handler if any. Notice that now we have two terminal configurations: (nil, v) (S1, throw) (no try/catch in S1) Finally, it's worth remarking that we can actually represent stack frames using lambda terms. In particular, each of the "holes" can be filled with a variable which is abstracted as part of the frame: F ::= \x.x e | \x.v x | \x.(x,e) | \x.(v,x) | \x.#i x | ... The process of "filling" a hole in a frame now is just a function application: F[e] = F e And stacks can be represented as compositions of frames with nil represented as the identity function: nil = \x.x F :: S = F o S So in some sense, our abstract machine can be viewed as a configuration consisting of a current expression e coupled with a function representing what we're supposed to do when we finish evaluating e. That function is called a continuation. What is the type of the stack? Well, the type of the empty stack (\x.x) is t->t where t is the type of the whole program. The type of the stack F::S if t'->t where S : t''->t and F : t'->t''. So in some sense, every stack/continuation is something of the form t'->t where t is the answer type of the program. So a configuration: (S,e) is well formed when S : t'->t and e : t'. The whole thing then has type S[e] : t. --------------------------------------------------------------- First-class continuations: Now that we see a continuation is really just a stack, and now that we've reified the stack in the abstract machine, we can make stacks first-class objects: v ::= ... | cont(S) e ::= ... | letcc x in e | throw e1 e2 A cont(S) value is a captured stack. The letcc operation binds x to a copy of the current stack. The throw operation takes a cont(S) and installs it as the current stack with e2's value as the thing we return to the stack: (S, letcc x in e) -> (S, e[cont(S)/x]) (S, throw cont(S') v) -> (S', v) Typing wise, it's useful to introduce a new constructor: t ::= ... | cont(t) with the idea that cont(S) is a stack that expects a t value to be "returned" to it, at which point it will return a value of the answer type of the whole program. Technically, we need to adjust all of the typing rules to keep track of the answer type. Logically, we can think of a continuation as something that has type t->void (think t implies false). The idea is that a continuation is like a function, except that when we call it, it doesn't return. Rather it returns to some alternate universe. So, we can say that it returns nothing (void). Interestingly, the addition of continuations to the language, in spite of the fact that they provide some twisted control, does not effect termination. That is, our extended language still has the property that every well-typed expression will eventually terminate.