Understanding Continuation Passing Style

I was reading through:

where the following snippet is provided showing how the continuation passing style (CPS) can be used to make a tail-recursive map function. They show:

(* Non-tail-recursive implementation of map *)
let rec map f = function
| [] -> []
| x :: r -> f x :: map f r

(* Tail-recursive CPS implementation of map *)
let rec map_cps f l k =
match l with
| [] -> k []
| x :: r ->  let fx = f x in map_cps f r (fun r -> fx :: r)

However, I can not for the life of me understand how the second form works. We’re passing in a closure as k, but k only ever gets called when l = []. My intuition says there should be a call to k in the closure. However, this also seems suspect; the closure has to live somewhere and so it looks as if the CPS version simply trades cheap stack space (which might overflow for large lists) for chunky heap allocations.

My functional background is mostly in Erlang where the idiomatic approach would be to have map construct the list in reverse order (which is trivially tail recursive) and then at the end reverse the list (which is a built in and also trivially tail recursive). Is the idea here that a sufficiently smart compiler can work with the CPS form to elide the allocations or have I totally missed the mark?

Regards, Freddie.

1 Like

The algorithm on that page is tail recursive, but not really “in CPS”. In CPS, by definition, all calls must be tail calls. So, in order to qualify, that f x must be tail position, which it is not. A more canonical CPS’d map is as follows (with f taken as being in CPS):

let rec map f xs k = match xs with
  | [] -> k []
  | x :: xs -> f x (fun x' -> map f xs (fun xs' -> k (x' :: xs')))

Or, with a nicety, define let (let@) = (@@), then you can write:

let rec map f xs k = match xs with
  | [] -> k []
  | x :: xs ->
    let@ x' = f x in
    let@ xs' = map f xs in
    k (x' :: xs')

so it looks as if the CPS version simply trades cheap stack space (which might overflow for large lists) for chunky heap allocations

This is exactly right. Your stack frames become heap allocated continuation closures. If you want an illustrative example of this, you can defunctionalise a CPS’d algorithm and you’ll find the resultant inductive datatype has the shape of a stack (with relevant free variables being carried around).

E.g. consider a factorial function in CPS, with k monomorphised (int -> 'a)['a := int]:

let rec fact n k = match n with
  | 0 -> k 1
  | n -> fact (n - 1) (fun n' -> k (n * n'))

let go n = fact n (fun n -> n)

We see the continuation (fun n' -> k (n * n')) : int -> int has the free variables { k : int -> int, n : int }. We see our other continuation (fun n -> n) : int -> int (with no free varibles). We’ll invent a datatype to represent functions of type int -> int. This will look like this:

type clos =
  | C0 (* fun n -> n *)
  | C1 of clos * int (* fun n' -> k (n' * n) *)

Now, without spelling everything out, we replace these higher-order functions with instances of these datatypes, hoist the bodies into a dispatch routine, and replace calls to int -> int functions with calls to this dispatch routine:

type clos =
  | C0 (* fun n -> n *)
  | C1 of clos * int (* fun n' -> k (n' * n) *)

let rec dispatch c arg = match c with
  | C0 -> arg (* fun n -> n *)
  | C1 (k, n) -> dispatch k (arg * n) (* fun n' -> k (n' * n) *)
and fact n k = match n with
  | 0 -> dispatch k 1
  | n -> fact (n - 1) (C1 (k, n))
and go n = fact n C0

let () = Printf.printf "fact(5) = %d\n" (go 5)

As you can see, clos is effectively a linked list carrying integers. This may seem kind of contrived, but it’s a good program transformation to know (see Danvy’s Defunctionalization at Work); I’ve converted a CPS’d OCaml algorithm into C doing this a few times.


It used to be somewhat common to rewrite OCaml functions into CPS to avoid the stack. However, as of OCaml 5, the stack is actually heap-allocated growable fibers (connected by parent pointers - i.e. a cactus stack). So, you’re far less likely to overflow the “stack” in OCaml 5. Still, CPS has actual algorithmic utility (e.g. see the classic A-Normal Form and CPS conversion algorithms, which are effectively in CPS). It goes without saying that monadic style is a generalisation of CPS, whereby the dispatching of the continuation (or not) is controlled by a guard (bind operator). So, it’s excellent background and really ought to be taught first.


As for compilers (the topic of that article you linked), you may be interested in Andrew Kennedy’s paper Compiling with Continuations, Continued which spells out some of the benefits of a CPS IR in a compiler. It has somewhat fallen out of fashion as a representation to lower faithfully (as in Appel’s “Compiling with Continuations”), but has garnered some recent interest as a mid-level IR for optimisation.

I suspect the implementation in the OCaml Pro post has a typo, and that it should be

(* Tail-recursive CPS implementation of map *)
let rec map_cps f l k =
  match l with
  | [] -> k []
  | x :: r ->  let fx = f x in map_cps f r (fun r -> k (fx :: r))
;;

With the continuation applied to the cons in the closure.

EDIT: re:

This is also very common in OCaml, and AFAIK, it is the more idiomatic approach if your aim is just to get a tail-call position version of these kinds of functions. That approach an accumulator argument rather than a continuation argument. See What is the use of Continuation Passing Style (CPS)? for related discussion.

(I also removed a link to a likely LLM spam source. Sorry about missing that initially!)

1 Like

@fdw I’m curious, how did you find that link ? The one we advertised was Flambda2 Ep. 1: Foundational Design Decisions | OCamlPro, and we fixed the typo there, but I wasn’t aware that we had the old version also available somewhere.

1 Like

From here:

which links out to:

Regards, Freddie.

I felt like this was sufficiently important that it was worth quoting it separately. If you perform the defunctionalization procedure (my source on that is the seminal Reynolds paper Defunctionalization - Wikipedia but Danvy’s work is certainly lovely, and FTR I always use his three-stage CPS conversion procedure) you end up converting the “continuation” into what is effectively an accumulating parameter.

It’s actually a lovely thing, the deep connection between accumulating parameters and CPS. And I hasten to for my money, the person who’s written most nicely about it, most pedagogically, is Danvy.

3 Likes

I especially like Jeremy Gibbons’ paper on the relation.

It was 34yr ago, but I remember when Olivier Danvy explained to me the deep relationship between CPS conversion (to A-normal form) and flattening trees into lists. It was long enough ago (and I was a different person: all that brain space has been taken up with Fast Paxos :wink: ) but I still remember the feeling of wonderment.

3 Likes

@fdw Hello there, i’m one of the authors of the F2S Series, very sorry that this occured but it seems that a link was forgotten when we updated the article after one of our readers pointed out the exact same oversight you encountered in the code block you mentioned. That being said, i’m going to fix this now - do make sure to refer to the latest version of that article to avoid any further confusion, again sorry for the inconvenience!

Dario

2 Likes