• 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 type expr.
  • Recall from our continuation-passing interpreter from last time that we interpreted each term with a default_cont and handler_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}$) to expr terms (denoted $\texttt{e}$) in the context of a default continuation $\kappa_d$ and handler continuation $\kappa_h$. The continuations here are expr 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
\[(\texttt{Num(10)}, \kappa_d, \kappa_h) \rightsquigarrow \texttt{App}(\kappa_d, \texttt{Num}(10))\]
  • 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)

\[\frac{}{(\texttt{Throw}, \kappa_d, \kappa_h) \rightsquigarrow \texttt{App}(\kappa_h,0)}\]
  • 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
\[\frac{ \text{Fresh}~r_1,r_2 \quad (\texttt{e}_2, \texttt{Lam}(r_2, \texttt{App}(\kappa_d, \texttt{Add}(r_1, r_2))), \kappa_h) \rightsquigarrow e_2' \qquad (e_1, \texttt{Lam}(r_1, e_2'), \kappa_h) \rightsquigarrow e_1' }{(\texttt{Add}(e_1, e_2), \kappa_d, \kappa_h) \rightsquigarrow e_1'}\]
  • The rule for exception handlers isn’t so bad: it simply compiles the try block with a new exception handler given by compiling the handle block
\[\frac {(\texttt{e}_h, \kappa_d, \kappa_h) \rightsquigarrow e_h' \quad (\texttt{e}, \kappa_d, \texttt{Lam}(\_, e_h')) \rightsquigarrow e_1' } {(\texttt{try}~\texttt{e}~\texttt{with}~\texttt{e}_h, \kappa_d, \kappa_h)\rightsquigarrow e_1'}\]
  • 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:
\[\dfrac { \dfrac{}{ (\texttt{Num}(20), \texttt{id}, \texttt{emp}) \rightsquigarrow \texttt{App}(\texttt{id},20) } \quad \dfrac{}{ (\texttt{Raise}, \texttt{id}, \texttt{Lam}(\_, \texttt{App}(\texttt{id},20))) \rightsquigarrow \texttt{App}(\texttt{Lam}(\_, \texttt{App}(\texttt{id},20)), 0) } } {(\texttt{try}~\texttt{Raise}~\texttt{with}~\texttt{Num}(20), \texttt{id}, \texttt{emp}) \rightsquigarrow \texttt{App}(\texttt{Lam}(\_, \texttt{App}(\texttt{id},20)), 0) }\]
  • 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):
\[\dfrac{ \dfrac{} {(20, \lambda r_2. \texttt{id}~(r_1 + r_2), \texttt{emp}) \rightsquigarrow (\lambda r_2. \texttt{id}~(r_1 + r_2))~20} \qquad \dfrac{}{ (10, \lambda r_1.(\lambda r_2. \texttt{id}~(r_1 + r_2))~20, \texttt{emp}) \rightsquigarrow (\lambda r_1.(\lambda r_2. \texttt{id}~(r_1 + r_2))~20)~10 }} {(10 + 20, \texttt{id}, \texttt{emp}) \rightsquigarrow (\lambda r_1.(\lambda r_2. \texttt{id}~(r_1 + r_2))~20)~10 }\]
  • 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):
\[\dfrac{} {(\texttt{Var}(s), \kappa_d, \kappa_h) \rightsquigarrow \texttt{App}(\kappa_d, \texttt{Var}(s))}\] \[\dfrac{\text{Fresh}~d,h \quad (e, \texttt{Var}(d), \texttt{Var(h)}) \rightsquigarrow e' } {(\texttt{Lam}(s, e), \kappa_d, \kappa_h) \rightsquigarrow \texttt{App}(\kappa_d, \texttt{Lam}(d, \texttt{Lam}(h, e'))) }\] \[\dfrac{ \text{Fresh}~r_1,r_2 \quad \left(e_1, \texttt{Lam}\Big(r_1, \texttt{App}(\texttt{App}(\texttt{App}(r_1, \kappa_d), \kappa_h), r_2)\Big)\right) \rightsquigarrow e_1' \quad (e_2, \texttt{Lam}(r_2, e_1'), \kappa_h) \rightsquigarrow e_2' } {(\texttt{App}(e_1, e_2), \kappa_d, \kappa_h) \rightsquigarrow e_2'}\]
  • 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))$):
\[\dfrac { \dfrac{}{(x, d, h) \rightsquigarrow d~x} } {(\lambda x . x, \kappa_h, \kappa_d) \rightsquigarrow \kappa_d~(\lambda d.\lambda h. \lambda x. d~x) }\]
  • 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:
\[\dfrac { \dfrac{\dfrac{}{(x, d, h) \rightsquigarrow d~x}}{ (\lambda x . x, \lambda r_1 . (r_1~\kappa_d~\kappa_h)~r_2, \kappa_h) \rightsquigarrow ((\lambda r_1 . (r_1~\kappa_d~\kappa_h)~r_2)~(\lambda d . \lambda h. \lambda x . d~x))} \quad \quad \dfrac{}{ (10, \lambda r_2.((\lambda r_1 . (r_1~\kappa_d~\kappa_h)~r_2)~(\lambda d . \lambda h. \lambda x . d~x)), \kappa_h) \rightsquigarrow (\lambda r_2.((\lambda r_1 . (r_1~\kappa_d~\kappa_h)~r_2)~(\lambda d . \lambda h. \lambda x . d~x))~10) } } {((\lambda x . x) ~ 10, \kappa_d, \kappa_h) \rightsquigarrow (\lambda r_2.((\lambda r_1 . (r_1~\kappa_d~\kappa_h)~r_2)~(\lambda d . \lambda h. \lambda x . d~x))~10) }\]
  • Wow that’s a mouthful! Let’s slowly step the resulting expression:
\[(\lambda r_2.((\lambda r_1 . (r_1~\kappa_d~\kappa_h)~r_2)~(\lambda d . \lambda h. \lambda x . d~x))~10) \\ \rightarrow ((\lambda r_1 . (r_1~\kappa_d~\kappa_h)~10)~(\lambda d . \lambda h. \lambda x . d~x)) \\ \rightarrow ((\lambda d . \lambda h. \lambda x . d~x)~\kappa_d~\kappa_h)~10 \\ \rightarrow (( \lambda h. \lambda x . \kappa_d~x)~\kappa_h)~10 \\ \rightarrow (\lambda x . \kappa_d~x)~10 \\ \rightarrow \kappa_d~10 \\\]
  • 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))