- Goals for today:
- We will see how to automatically transform programs into continuation-passing style
- We will use this automated transformation to compile a language with exceptions into a language without exceptions
- Logistics
- Next assignment is out
- Next lecture Sam is guest lecturing
- Trace evaluations are out, please complete them
Compiling away exceptions
- A major theme of this course is compilation, a process for transforming one language into another language while preserving the underlying semantics
- Often, we’ve used it in order to study and better understand properties of both of those languages: for instance, to study how dynamic safety works under the hood, we implemented a compiler for a language with dynamic safety into assembly language
- To better understand continuations and continuation-passing style, we will study how to compile a language with interesting control-flow constructs (such as exceptions and exception handlers) into a language without such control-flow constructs
- This can be extremely useful. For instance, it may be useful to have exception-handlers in a language like C, which has no built-in support for exceptions
- First, let’s see our source language:
type exn_expr =
| Num of int
| Add of exn_expr * exn_expr
| Try_with of { try_e: exn_expr; handle_e: exn_expr }
| Raise
- This language has the same semantics for exceptions as we saw in the previous 2 lectures, and matches OCaml’s exceptions: if a
Raise
term is encountered during interpretation, we immediately jump to the inner-most handler and resume execution from that point - We will use the lambda calculus as our target language:
type expr =
| Lam of string * expr
| Var of string
| App of expr * expr
| Num of int
| Add of expr * expr
- Let’s take a moment to consider some examples of how we might compile terms of type
exn_expr
into terms of typeexpr
. - Recall from our continuation-passing interpreter from last time that we interpreted each term with a
default_cont
andhandler_cont
. To implement our compiler, we will follow a very similar structure, except instead of relying on the host language (OCaml) to construct and evaluate the continuation, we will emit lambda calculus terms - To describe this compilation, we will define a relation: \((\texttt{e}_\text{exn}, \kappa_d, \kappa_h) \rightsquigarrow \texttt{e}\) that relates
exn_expr
terms (denoted $\texttt{e}_\text{exn}$) toexpr
terms (denoted $\texttt{e}$) in the context of a default continuation $\kappa_d$ and handler continuation $\kappa_h$. The continuations here areexpr
syntax. - We will call this $\rightsquigarrow$ relation a continuation-passing transformation: it transforms an input expression into one that is in continuation-passing style
- Let’s see some examples of this compilation before we try to write it in full generality. The base case is simple: to compile a number in the context of two continuations $\kappa_d$ and $\kappa_h$, simply call the default continuation with that number as an argument
Note that $\kappa_d$ and $\kappa_h$ are syntactic terms in the lambda calculus! So, this compilation is yielding a function call in the lambda calculus: it calls whatever $\kappa_d$ is with the argument
10
.The base case of a number simply invokes the $\kappa_d$ continuation on the number: \(\frac{}{(\texttt{Num(n)}, \kappa_d, \kappa_h) \rightsquigarrow \texttt{App}(\kappa_d, \texttt{Num}(n))}\)
The base case of throw invokes the handler continuation with a default argument of 0 (we could extend our language with
unit
to not have to deal with this default argument)
- The case of $\texttt{Add}(e_1, e_2), \kappa_d, \kappa_h$ is more intricate. There will be two continuation arguments, $r_1$ and $r_2$, that hold the result of evaluating $e_1$ and $e_2$ respectively.
- The rule compiles $e_2$ with a default continuation “finishes the addition” by applying the $\kappa_d$ to the result of adding $r_1$ and $r_2$, similar to our continuation-passing interpreter
- Finally, the rule compiles $e_1$ with a continuation that invokes the inner continuation
- The rule for exception handlers isn’t so bad: it simply compiles the
try
block with a new exception handler given by compiling thehandle
block
- Let’s see some example applications of these rules ; the syntax $\texttt{id}$ is short for the identity function, and $\texttt{emp}$ is a default exception handler:
- Here is a more involved example involving addition (these will use shorthand syntax rather than the full AST as we’ve been using above, since this example has a lot of syntax):
- Now that we’ve seen the rules, the implementation isn’t so bad and looks a lot like the rules:
let rec compile_k_h (e:exn_expr) (default_cont:expr) (handle_cont:expr) : expr =
match e with
| Num(n) -> App(default_cont, Num(n))
| Add(e1, e2) ->
let ret1 = fresh () in
let ret2 = fresh () in
let perform_sum = compile_k_h e2 (Lam(ret2, App(default_cont, Add(Var(ret1), Var(ret2))))) handle_cont in
compile_k_h e1 (Lam(ret1, perform_sum)) handle_cont
| Try_with { try_e; handle_e } ->
let compiled_handle = compile_k_h handle_e default_cont handle_cont in
compile_k_h try_e default_cont (Lam("_", compiled_handle))
| Raise -> App(handle_cont, Num(0))
Compiling away exceptions from the lambda calculus
- Let’s extend our grammar to add lambda terms:
type exn_expr =
Lam of string * exn_expr
| Var of string
| App of exn_expr * exn_expr
| Num of int
| Add of exn_expr * exn_expr
| Try_with of { try_e: exn_expr; handle_e: exn_expr }
| Raise
- Now our goal is to compile this new extended
exn_expr
language into the lambda calculus - Complete rules (we only have to add new rules for the new terms; we use the same rules as above for every term that isn’t listed here):
- Example derivations (to save a lot of space, we are using the usual lambda calculus syntax; so, we write $(\lambda x . x)~10$ instead of $\texttt{App}(\texttt{Lam}(x, \texttt{Var}(x)), \texttt{Num}(10))$):
- Notice the structure above: we have added two extra arguments to the lambda (one for a default continuation $d$ and another for a handler continuation $h$). The convention is that whenever we call a lambda, we pass in the default continuation first, then the handler continuation, the the usual argument to the lambda.
- To really see how all these parts play together, we need to see a full example of a function call:
- Wow that’s a mouthful! Let’s slowly step the resulting expression:
- Wow, that’s an awful lot of work to call the identity function on 10! But, it is what it is.
- OCaml implementation:
let rec compile_k_h (e:exn_expr) (default_cont:expr) (handle_cont:expr) : expr =
match e with
| Num(n) -> App(default_cont, Num(n))
| Lam(id, body) ->
(* build a new lambda abstraction that takes the default and handle
continuations as arguments *)
let default_name = fresh () in
let handle_name = fresh () in
let compiled_body = compile_k_h body (Var(default_name)) (Var(handle_name)) in
App(default_cont, Lam(default_name, Lam(handle_name, Lam(id, compiled_body))))
| Var(s) -> App(default_cont, Var(s))
| App(e1, e2) ->
let fun_name = fresh () in
let arg_name = fresh () in
let call_function = compile_k_h e1
(Lam(arg_name,
App(App(App(Var(fun_name), default_cont), handle_cont), Var(arg_name)))) handle_cont in
compile_k_h e2 (Lam(fun_name, call_function)) handle_cont
| Add(e1, e2) ->
let ret1 = fresh () in
let ret2 = fresh () in
let perform_sum = compile_k_h e2 (Lam(ret2, Add(Var(ret1), Var(ret2)))) handle_cont in
compile_k_h e1 (Lam(ret1, perform_sum)) handle_cont
| Try_with { try_e; handle_e } ->
let compiled_handle = compile_k_h handle_e default_cont handle_cont in
compile_k_h try_e default_cont (Lam("_", compiled_handle))
| Raise -> App(handle_cont, Num(0))