• 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 to fib, 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 a fib 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) a raise 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 an apply_cont function of type cont -> int -> int that applies the continuation to the argument
  • The apply_cont function is a mutually-recursive function with interp_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 ()