๐Ÿฆ€ Functional Rust

Example 1082: Tail-Recursive Map with CPS

Difficulty: โญโญ Category: Higher-Order Functions OCaml Source: Cornell CS3110 โ€” Functional Programming in OCaml

Problem Statement

Implement `List.map` in three ways: naive recursive (stack-unsafe), tail-recursive with an accumulator and reverse, and continuation-passing style (CPS). Show how each approach handles the stack-safety problem.

Learning Outcomes

OCaml Approach

OCaml guarantees tail-call optimization, so rewriting `map` with an accumulator parameter (`go acc = function ...`) makes it stack-safe. The CPS version achieves the same by passing a continuation closure โ€” all recursive calls are in tail position. Both `map_tr` and `map_cps` handle million-element lists without overflow.

Rust Approach

Rust does not guarantee TCO, so even structurally tail-recursive functions can overflow. The idiomatic Rust translation of OCaml's accumulator pattern is a `for` loop with `Vec::push`. The CPS version uses `Box<dyn FnOnce>` to build a closure chain, which is educational but applies the chain via nested calls (still O(n) stack). The truly idiomatic solution is `iter().map().collect()`.

Key Differences

1. Tail-call optimization: OCaml guarantees TCO; Rust does not. Structural tail recursion in Rust still overflows. 2. List construction: OCaml cons (`::`) prepends in O(1), requiring `List.rev` at the end. Rust's `Vec::push` appends in O(1) amortized โ€” no reverse needed. 3. Continuations: OCaml closures are lightweight GC-managed values. Rust requires `Box<dyn FnOnce>` heap allocation for each continuation in the chain. 4. Idiomatic solution: OCaml developers write `List.map`. Rust developers write `iter().map(f).collect()` โ€” both are zero-overhead abstractions over the same pattern.
// === Solution 1: Naive recursive map ===
// Not tail-recursive โ€” stack overflows on large inputs.
pub fn map_naive<T, U, F: Fn(&T) -> U>(f: &F, list: &[T]) -> Vec<U> {
    match list {
        [] => vec![],
        [head, tail @ ..] => {
            let mut result = vec![f(head)];
            result.extend(map_naive(f, tail));
            result
        }
    }
}

// === Solution 2: Accumulator-based map (loop) ===
// Rust translation of OCaml's tail-recursive `go acc = function | [] -> List.rev acc | h::t -> go (f h :: acc) t`
// Vec::push appends in order (unlike OCaml cons which prepends), so no reverse needed.
pub fn map_acc<T, U, F: Fn(&T) -> U>(f: &F, list: &[T]) -> Vec<U> {
    let mut acc = Vec::with_capacity(list.len());
    for item in list {
        acc.push(f(item));
    }
    acc
}

// === Solution 3: CPS (Continuation-Passing Style) ===
// Builds a chain of boxed closures, each wrapping the previous one.
pub fn map_cps<T, U, F: Fn(&T) -> U>(f: &F, list: &[T]) -> Vec<U> {
    let mut cont: Box<dyn FnOnce(Vec<U>) -> Vec<U>> = Box::new(|v| v);

    for item in list.iter().rev() {
        let mapped = f(item);
        let prev = cont;
        cont = Box::new(move |mut rest: Vec<U>| {
            rest.push(mapped);
            prev(rest)
        });
    }

    cont(Vec::new())
}

// === Solution 4: Idiomatic Rust ===
pub fn map_idiomatic<T, U, F: Fn(&T) -> U>(f: F, list: &[T]) -> Vec<U> {
    list.iter().map(f).collect()
}

fn main() {
    let input = [1, 2, 3, 4, 5];
    let double = |x: &i32| x * 2;

    println!("Input: {:?}", input);
    println!();
    println!("map_naive:    {:?}", map_naive(&double, &input));
    println!("map_acc:      {:?}", map_acc(&double, &input));
    println!("map_cps:      {:?}", map_cps(&double, &input));
    println!("map_idiomatic:{:?}", map_idiomatic(&double, &input));

    // Demonstrate that acc and idiomatic handle large inputs
    let big: Vec<i32> = (0..1_000_000).collect();
    let result = map_acc(&|x: &i32| x * 2, &big);
    println!();
    println!(
        "map_acc on 1M elements: len={}, first={}, last={}",
        result.len(),
        result[0],
        result[999_999]
    );

    let result = map_idiomatic(|x: &i32| x * 2, &big);
    println!(
        "map_idiomatic on 1M elements: len={}, first={}, last={}",
        result.len(),
        result[0],
        result[999_999]
    );
}

/* Output:
   Input: [1, 2, 3, 4, 5]

   map_naive:    [2, 4, 6, 8, 10]
   map_acc:      [2, 4, 6, 8, 10]
   map_cps:      [2, 4, 6, 8, 10]
   map_idiomatic:[2, 4, 6, 8, 10]

   map_acc on 1M elements: len=1000000, first=0, last=1999998
   map_idiomatic on 1M elements: len=1000000, first=0, last=1999998
*/
(* Naive map โ€” not tail-recursive, stack overflow on large lists *)
let rec map_naive f = function
  | [] -> []
  | h :: t -> f h :: map_naive f t

(* Tail-recursive with reverse โ€” accumulates in reverse, then flips *)
let map_tr f lst =
  let rec go acc = function
    | [] -> List.rev acc
    | h :: t -> go (f h :: acc) t
  in go [] lst

(* CPS โ€” tail-recursive, preserves order without reversing *)
let map_cps f lst =
  let rec go k = function
    | [] -> k []
    | h :: t -> go (fun rest -> k (f h :: rest)) t
  in go Fun.id lst

(* Tests *)
let () =
  (* Basic functionality *)
  assert (map_naive (fun x -> x * 2) [1;2;3;4] = [2;4;6;8]);
  assert (map_tr (fun x -> x * 2) [1;2;3;4] = [2;4;6;8]);
  assert (map_cps (fun x -> x * 2) [1;2;3;4] = [2;4;6;8]);

  (* Empty list *)
  assert (map_naive (fun x -> x + 1) [] = []);
  assert (map_tr (fun x -> x + 1) [] = []);
  assert (map_cps (fun x -> x + 1) [] = []);

  (* Single element *)
  assert (map_naive (fun x -> x + 1) [5] = [6]);
  assert (map_tr (fun x -> x + 1) [5] = [6]);
  assert (map_cps (fun x -> x + 1) [5] = [6]);

  (* Large input โ€” only tail-recursive versions survive *)
  let big = List.init 1_000_000 Fun.id in
  let result = map_tr (fun x -> x * 2) big in
  assert (List.length result = 1_000_000);
  assert (List.hd result = 0);
  assert (List.nth result 999_999 = 1_999_998);

  let result_cps = map_cps (fun x -> x * 2) big in
  assert (List.length result_cps = 1_000_000);
  assert (List.hd result_cps = 0);

  print_endline "ok"

๐Ÿ“Š Detailed Comparison

OCaml vs Rust: Tail-Recursive Map with CPS

Side-by-Side Code

OCaml

๐Ÿช Show OCaml equivalent
(* Naive โ€” not tail-recursive *)
let rec map_naive f = function
| [] -> []
| h :: t -> f h :: map_naive f t

(* Tail-recursive with accumulator + reverse *)
let map_tr f lst =
let rec go acc = function
 | [] -> List.rev acc
 | h :: t -> go (f h :: acc) t
in go [] lst

(* CPS โ€” tail-recursive, preserves order *)
let map_cps f lst =
let rec go k = function
 | [] -> k []
 | h :: t -> go (fun rest -> k (f h :: rest)) t
in go Fun.id lst

Rust (idiomatic)

pub fn map_idiomatic<T, U, F: Fn(&T) -> U>(f: F, list: &[T]) -> Vec<U> {
 list.iter().map(f).collect()
}

Rust (accumulator loop โ€” equivalent of OCaml's tail-recursive version)

pub fn map_acc<T, U, F: Fn(&T) -> U>(f: &F, list: &[T]) -> Vec<U> {
 let mut acc = Vec::with_capacity(list.len());
 for item in list {
     acc.push(f(item));
 }
 acc
}

Rust (CPS with boxed closures)

pub fn map_cps<T, U, F: Fn(&T) -> U>(f: &F, list: &[T]) -> Vec<U> {
 let mut cont: Box<dyn FnOnce(Vec<U>) -> Vec<U>> = Box::new(|v| v);
 for item in list.iter().rev() {
     let mapped = f(item);
     let prev = cont;
     cont = Box::new(move |mut rest: Vec<U>| {
         rest.push(mapped);
         prev(rest)
     });
 }
 cont(Vec::new())
}

Type Signatures

ConceptOCamlRust
Naive map`val map_naive : ('a -> 'b) -> 'a list -> 'b list``fn map_naive<T, U, F: Fn(&T) -> U>(f: &F, list: &[T]) -> Vec<U>`
Accumulator map`val map_tr : ('a -> 'b) -> 'a list -> 'b list``fn map_acc<T, U, F: Fn(&T) -> U>(f: &F, list: &[T]) -> Vec<U>`
CPS map`val map_cps : ('a -> 'b) -> 'a list -> 'b list``fn map_cps<T, U, F: Fn(&T) -> U>(f: &F, list: &[T]) -> Vec<U>`
Continuation type`'b list -> 'b list` (implicit)`Box<dyn FnOnce(Vec<U>) -> Vec<U>>` (explicit heap allocation)
List type`'a list` (singly-linked, immutable)`&[T]` input / `Vec<U>` output (contiguous, mutable)

Key Insights

1. TCO is the fundamental difference. OCaml guarantees tail-call optimization, making structural recursion with an accumulator genuinely stack-safe. Rust does not โ€” the same recursive structure still grows the call stack. Rust's equivalent is an explicit loop.

2. Cons vs push inverts accumulation order. OCaml's `::` prepends to a linked list in O(1), naturally building in reverse. That's why `List.rev` is needed at the end. Rust's `Vec::push` appends in O(1) amortized, building in forward order โ€” the reverse step disappears entirely.

3. Continuations require explicit heap allocation in Rust. OCaml closures are lightweight GC-managed values โ€” building a chain of continuations is cheap. In Rust, each continuation must be `Box<dyn FnOnce(...)>`, requiring a heap allocation per list element. This makes CPS a poor fit for performance-sensitive Rust code.

4. CPS in Rust is educational, not practical. Applying the continuation chain still involves O(n) nested function calls (each continuation calls the previous one), so it can still overflow. The technique demonstrates the concept but doesn't solve the stack-safety problem the way it does in OCaml.

5. Iterator chains are Rust's zero-cost abstraction for this pattern. `iter().map(f).collect()` is the idiomatic Rust solution โ€” it compiles to a tight loop with no recursion, no heap-allocated closures, and no intermediate allocations beyond the output `Vec`.

When to Use Each Style

Use the accumulator loop (`map_acc`) when: you need an explicit loop body with complex logic that doesn't fit a single closure โ€” e.g., conditional mapping, stateful transforms, or early termination.

Use CPS (`map_cps`) when: you're teaching or studying continuation-passing style. It's valuable for understanding how OCaml achieves tail recursion through continuations, but it's not the right tool for production Rust.

Use idiomatic Rust (`map_idiomatic`) when: this is the default choice. Iterator chains are the Rust way โ€” zero-cost, composable, and optimized by the compiler.