- Goals for today:
- Continue to see more examples of continuations and how they are used
- See how exceptions can be implemented using continuations
- See how we can use continuations in languages that do not have first-class functions (lambdas)
- See how continuations are a useful tool for compiling functional programming languages into assembly language
- Logistics
- HW next Monday
- Trace evaluations are out, please complete them
More tail-form: Fibonacci
- Recall our important definitions from last time:
- A tail call is a function call that occurs as the final operation of a function.
- A function is in tail form if all function calls are tail calls.
- Let’s consider a more complicated function than
fact
and try to write it in tail-form:
(* Fibonacci sequence:
a0 = 0
a1 = 1
an = a_{n-1} + a_{n-2}
Example:
a0 = 0
a1 = 1
a2 = 1
a3 = 2
a4 = 3
a5 = 5
... *)
let rec fib (a:int) =
if a = 0 then 0
else if a = 1 then 1
else fib (a-1) + fib (a-2)
- It seems difficult to write
fib
in tail-form because there are two separate calls tofib
, neither of which are tail calls! - The answer is to use first-class functions: we add a parameter called a continuation of type
int -> int
that takes as input the result of evaluating afib
call and “continues” the computation. This is best illustrated by example:
(* continuation-passing-style fibonacci *)
let rec fib_iter_h (a:int) (cont : int -> int) =
if a = 0 then cont 0
else if a = 1 then cont 1
else fib_iter_h (a-1)
(fun res1 -> fib_iter_h (a-2)
(fun res2 -> cont (res1 + res2)))
let fib_iter a = fib_iter_h a (fun result -> result)
Implementing exception handling
- Return expressions are interesting (and go beyond what we could do without an explicit continuation), but they are relatively simple control-flow constructs (simply a jump)
- Let’s consider some more interesting control-flow constructs: exceptions
- Recall how exceptions usually work in OCaml (repeating example from above):
exception Oops
let () =
try
let x = 10 in
if x < 4 then raise Oops else Format.printf "aaah"
with
Oops -> Format.printf "ouch!"
- So, exception handling consist of a (1)
try...with
block, and (2) araise
keyword that triggers a jump to the exception handler - Our exceptions will differ from OCaml’s to simplify our lives: we will not provide arguments to the handler, and we will not have different kinds of exceptions
- How should we go about implementing this?
- We will need some additional data to be stored in our interpreter
- We will need the default continuation, called
cont
as before. This continuation will be called in the event that no exception is raised. - And, we will need the
handler
continuation that is called in the event that there is an exception.
type ecalc =
Num of int
| Bool of bool
| And of ecalc * ecalc
| Add of ecalc * ecalc
| Try_with of {try_e: ecalc; with_e: ecalc}
| Raise
exception Uncaught_exception
let rec interp_ecalc_iter_h (c:ecalc) (cont : value -> value) (handler: unit -> value) =
match c with
| Num(n) -> cont (VNum n)
| Bool(b) -> cont (VBool b)
| Add(e1, e2) ->
interp_ecalc_iter_h e1
(fun v1 -> interp_ecalc_iter_h e2 (fun v2 ->
let sum = match (v1, v2) with
| (VNum(n1), VNum(n2)) -> VNum(n1 + n2)
| _ -> raise Runtime in
cont sum) handler) handler
| And(e1, e2) ->
interp_ecalc_iter_h e1
(fun v1 -> interp_ecalc_iter_h e2 (fun v2 ->
let sum = match (v1, v2) with
| (VBool(n1), VBool(n2)) -> VBool(n1 && n2)
| _ -> raise Runtime in
cont sum) handler) handler
| Raise -> handler ()
| Try_with { try_e; with_e } ->
(* run try_e with a new continuation that evaluates with_e *)
let handler_cont = fun () -> interp_ecalc_iter_h with_e cont handler in
interp_ecalc_iter_h try_e cont handler_cont
let interp_ecalc (e:ecalc) =
interp_ecalc_iter_h e (fun x -> x) (fun x -> raise Uncaught_exception)
let p1 = Try_with { try_e = Raise;
with_e = Num(10)}
let p2 = Try_with { try_e = Add(Num(10), Num(20));
with_e = Num 20}
let p3 = Try_with { try_e = Add(Num(10), Raise);
with_e = Num 20}
let p4 = Try_with { try_e = Try_with { try_e = Raise;
with_e = Num 30} ;
with_e = Num 20}
let () =
assert ((interp_ecalc p1) = VNum(10));
assert ((interp_ecalc p2) = VNum(30));
assert ((interp_ecalc p3) = VNum(20));
assert ((interp_ecalc p4) = VNum(30))
A continuation data-type
- Now we begin our journey into implementing continuations in languages that do not support higher-order functions
- Many important programming languages today don’t have lambdas, for instance C and assembly.
- If we want to make efficient compilers for functional languages with interesting control flow, it will be very useful to be able to represent continuations using only the built-in capabilities of these languages: namely, function calls and structs
- To do this, we will introduce a continuation datatype called
cont
. This datatype will hold all the different kinds of continuations that we encounter in our interpreter.- In addition to being implementable in languages without higher-order functions, it can the continuation data-type can be useful for understanding the core structure of the continuation.
- Paired with
cont
, we will have anapply_cont
function of typecont -> int -> int
that applies the continuation to the argument - The
apply_cont
function is a mutually-recursive function withinterp_h
(they can each recursively call each other), which requires a tiny bit more OCaml syntax to write:
(* representing continuations as a datatype *)
type expr =
| Add of expr * expr
| Num of int
(* rather than capturing the context for the continuation in a closure, we can
instead capture this context in a data-stucture. This can make continuations
easier to read and understand, and make it possible to implement continuations
in languages that do not support first-class functions. *)
type cont =
(* end the continuation *)
| End
(* continue the computation after evaluating the first argument of an addition *)
| AddCont1 of expr * cont
(* continue the computation after evaluating the second argument of an addition *)
| AddCont2 of int * cont
let rec apply_cont (k:cont) (v:int) : int =
match k with
| End -> v (* return the final value, we are done *)
| AddCont1(e, saved_k) -> interp_h e (AddCont2(v, saved_k))
| AddCont2(i, saved_k) -> apply_cont saved_k (i + v)
and interp_h (e:expr) (k:cont) : int =
match e with
| Num(n) -> apply_cont k n
| Add(e1, e2) -> interp_h e1 (AddCont1(e2, k))
let interp (e:expr) : int = interp_h e End
Adding let-bindings
- Now it’s a useful exercise to grow our interpreter: let’s add let-bindings and see how we capture the environment in the continuation
module StringMap = Map.Make(String)
type env = int StringMap.t
type expr =
| Add of expr * expr
| Num of int
| Let of string * expr * expr
| Var of string
type cont =
End
| AddCont1 of expr * env * cont
| AddCont2 of int * env * cont
| LetCont of string * env * expr * cont
exception Runtime
let rec apply_cont (k:cont) (v:int) : int =
match k with
| End -> v
| AddCont1(e, env, saved_k) -> interp_h e env (AddCont2(v, env, saved_k))
| AddCont2(i, env, saved_k) -> apply_cont saved_k (i + v)
| LetCont(x, env, e2, saved_cont) ->
let new_env = StringMap.add x v env in
interp_h e2 new_env saved_cont
and interp_h (e:expr) (env:env) (k:cont) : int =
match e with
| Num(n) -> apply_cont k n
| Add(e1, e2) -> interp_h e1 env (AddCont1(e2, env, k))
| Var(x) ->
(match StringMap.find_opt x env with
| Some(v) -> apply_cont k v
| None -> raise Runtime)
| Let(x, e1, e2) ->
interp_h e1 env (LetCont(x, env, e2, k))
let interp (e:expr) : int = interp_h e (StringMap.empty) End
- In an implementation that makes use of first-class functions, the closure automatically captures the context necessary to apply the continuation
- In a continuation data-type, you need to explicitly include all captured data in the data-type.
An imperative interpreter and registerization
- Suppose we want to implement our interpreter in a version of (micro-)ASM
- Micro-ASM doesn’t have variables, but for the sake of argument pretend that it has a
jmp
instruction, for implementing recursion - A powerful fact about our tail-form interpreter tail calls never return execution back to the caller: a tail call jumps to the new execution point
- Hence, we don’t need to worry about local variables in the caller: the callee can freely assume that it owns all local data (concretely, a callee can change a register’s value without worrying about the caller depending on that same register)
- This leads us to our final version of a continuation-passing interpreter: one whose function calls don’t make use of any arguments at all! All function calls in this imperative interpreter are tail-calls that take no arguments: they behave functionally like
goto
statements - This is an essential step for how real functional programs are compiled to run on assembly (except argumentless tail-calls are replace by
jmp
instructions) - The essential idea is to make an interpreter that is basically identical to the let-binding interpreter above, except that instead of calling functions with arguments, we update and read from the relevant registers.
let env_reg = ref StringMap.empty
let cont_reg = ref End
let value_reg = ref 0
let expr_reg = ref (Num(0))
let rec apply_reg_cont () : int =
match !cont_reg with
| End -> !value_reg
| AddCont1(e, env, saved_k) ->
expr_reg := e;
env_reg := env;
cont_reg := (AddCont2(!value_reg, env, saved_k));
interp_reg_h ()
| AddCont2(i, env, saved_k) ->
cont_reg := saved_k;
value_reg := (i + !value_reg);
apply_reg_cont ()
| LetCont(x, env, e2, saved_cont) ->
let new_env = StringMap.add x (!value_reg) env in
env_reg := new_env;
expr_reg := e2;
cont_reg := saved_cont;
interp_reg_h ()
and interp_reg_h () : int =
match !expr_reg with
| Num(n) -> apply_cont !cont_reg n
| Add(e1, e2) ->
expr_reg := e1;
cont_reg := (AddCont1(e2, !env_reg, !cont_reg));
interp_reg_h ()
| Var(x) ->
(match StringMap.find_opt x !env_reg with
| Some(v) ->
value_reg := v;
apply_reg_cont ()
| None -> raise Runtime)
| Let(x, e1, e2) ->
cont_reg := (LetCont(x, !env_reg, e2, !cont_reg));
expr_reg := e1;
interp_reg_h ()
let interp_reg (e:expr) : int =
expr_reg := e;
env_reg := (StringMap.empty);
cont_reg := End;
interp_reg_h ()