🦀 Functional Rust

190: Effect Handler — Logging and Nondeterminism

Difficulty: ⭐⭐⭐⭐⭐ Level: Expert Two effect handler patterns: a logging handler that collects messages without interrupting computation, and a nondeterminism handler that runs all branches and returns every possible result.

The Problem This Solves

Logging is normally a side effect woven through code — `log::info!("...")` statements scattered everywhere, writing to a global logger. You can't easily capture logs in tests without configuring logging infrastructure. You can't count log entries without parsing output. The logs are effects that escape the computation, and reigning them back in requires mocking at the infrastructure level. Nondeterminism is worse — most languages have no native support for "run this code for each possible choice and collect all results." You'd write explicit loops, maintain intermediate lists, propagate results manually. The code that generates results and the code that collects them are tangled together. Effect handlers give you a clean separation for both. A `Log` effect says "record this message." The handler decides what to do with it: collect to a vec, print to stdout, count occurrences, discard. A `Choose` effect says "pick one of these values." The handler decides to run the continuation once per option and combine results. The computation stays pure; the handler owns the policy.

The Intuition

Logging: A writer in a recording studio wears noise-cancelling headphones and writes lyrics, occasionally calling out "this line is temporary" to their assistant. The assistant writes it down without interrupting the lyrics session. Later, they review the notes. The logging effect is like calling out — the writer (computation) doesn't change behavior, but the assistant (handler) captures every call. Nondeterminism: A story with branching paths: "You find a fork in the road. Take the left path, or the right path?" An exhaustive reader reads the story once for each possible choice and collects all endings. That's the nondeterminism handler — for each `Choose [1, 2, 3]`, run the rest of the computation three times and gather all results. This is exactly the list monad: `bind` for lists is `flat_map`.

How It Works in Rust

// ── Logging Effect ─────────────────────────────────────────────────────

enum LogStep<A> {
 Done(A),
 Log(String, Box<dyn FnOnce() -> LogStep<A>>),  // log this message, then continue
}

fn log_msg<A: 'static>(msg: impl Into<String>, next: LogStep<A>) -> LogStep<A> {
 LogStep::Log(msg.into(), Box::new(move || next))
}

// Handler: collect all log messages
fn collect_logs<A>(mut step: LogStep<A>) -> (A, Vec<String>) {
 let mut logs = Vec::new();
 loop {
     match step {
         LogStep::Done(x) => return (x, logs),
         LogStep::Log(msg, cont) => {
             logs.push(msg);    // intercept the log message
             step = cont();     // resume the computation
         }
     }
 }
}

// Usage:
let program = log_bind(
 log_msg("starting", log_done(())),
 |_| log_bind(
     log_msg("x = 42", log_done(())),
     |_| log_done(84_i32)
 )
);
let (result, logs) = collect_logs(program);
// result = 84, logs = ["starting", "x = 42"]

// ── Nondeterminism Effect: the list monad ──────────────────────────────

// "Choose" doesn't need a tree structure — it maps directly to the list monad
// bind for lists = flat_map: run f for every element, collect all results
fn nd_bind<A, B, F: Fn(A) -> Vec<B>>(xs: Vec<A>, f: F) -> Vec<B> {
 xs.into_iter().flat_map(f).collect()
}

fn nd_choose<A: Clone>(xs: Vec<A>) -> Vec<A> { xs }  // "perform Choose xs"
fn nd_guard(cond: bool) -> Vec<()> { if cond { vec![()] } else { vec![] } }

// Find all pairs (a,b) where a+b == 5, a,b in [1..4]:
let pairs = nd_bind(nd_choose(vec![1, 2, 3, 4]), |a| {
 nd_bind(nd_choose(vec![1, 2, 3, 4]), move |b| {
     nd_bind(nd_guard(a + b == 5), move |_| vec![(a, b)])
 })
});
// [(1,4), (2,3), (3,2), (4,1)] — all combinations, automatically

// Pythagorean triples — same pattern, different predicate:
let triples = nd_bind(nd_choose((1..=10).collect()), |a| {
 nd_bind(nd_choose((a..=10).collect()), move |b| {
     nd_bind(nd_choose((b..=10).collect()), move |c| {
         nd_bind(nd_guard(a*a + b*b == c*c), move |_| vec![(a,b,c)])
     })
 })
});
The nondeterminism handler reveals a deep truth: the list monad _is_ the handler for a Choose effect. In OCaml, `continue k x` runs the rest of the program with choice `x`; collecting all results means calling `List.concat_map (fun x -> continue k x) xs`. That's exactly `flat_map`.

What This Unlocks

Key Differences

ConceptOCamlRust
Logging handler`match program () with \effect (Log msg) k -> log := msg :: !log; continue k ()``loop { match step { LogStep::Log(msg, cont) => { logs.push(msg); step = cont(); } } }`
Nondeterminism handler`match program () with \effect (Choose xs) k -> List.concat_map (fun x -> continue k x) xs``nd_bind(nd_choose(xs), f)` — list monad; no native continuation capture needed
Resuming`continue k value` — built-in, can resume multiple times (for nondeterminism)Closure called once per branch; for nondeterminism, `flat_map` handles branching implicitly
Effect compositionSame `match` handles both Log and Choose — naturally composableSeparate `LogStep` and list monad — composing requires nesting or a combined effect type
PurityOCaml 5 effects are truly first-class; can mix Log and Choose in one programRust simulation separates them; mixing requires a unified effect enum
// Effect Handler — Logging and Nondeterminism
//
// Two separate effect systems:
//   1. Logging: collect log messages without interrupting computation
//   2. Nondeterminism: Choose from a list — the handler produces ALL results
//
// Simulated with the interpreter pattern.  Each "perform" is encoded as a
// data constructor; the handler interprets the tree.

// ── Logging Effect ────────────────────────────────────────────────────────────

enum LogStep<A> {
    Done(A),
    Log(String, Box<dyn FnOnce() -> LogStep<A>>),
}

fn log_done<A>(x: A) -> LogStep<A> {
    LogStep::Done(x)
}

fn log_msg<A: 'static>(msg: impl Into<String>, next: LogStep<A>) -> LogStep<A> {
    LogStep::Log(msg.into(), Box::new(move || next))
}

fn log_bind<A: 'static, B: 'static, F: FnOnce(A) -> LogStep<B> + 'static>(
    step: LogStep<A>,
    f: F,
) -> LogStep<B> {
    match step {
        LogStep::Done(x) => f(x),
        LogStep::Log(msg, cont) => {
            LogStep::Log(msg, Box::new(move || log_bind(cont(), f)))
        }
    }
}

/// Run a logging program: collect all log messages.
fn collect_logs<A>(mut step: LogStep<A>) -> (A, Vec<String>) {
    let mut logs = Vec::new();
    loop {
        match step {
            LogStep::Done(x) => return (x, logs),
            LogStep::Log(msg, cont) => {
                logs.push(msg);
                step = cont();
            }
        }
    }
}

// ── Nondeterminism via the list monad ────────────────────────────────────────
//
// "Choose" from a list = branch on every element.
// The handler runs all branches and concatenates results.
// This is exactly the list monad: bind = flat_map.

fn nd_return<A: Clone>(x: A) -> Vec<A> {
    vec![x]
}

fn nd_bind<A, B, F: Fn(A) -> Vec<B>>(xs: Vec<A>, f: F) -> Vec<B> {
    xs.into_iter().flat_map(f).collect()
}

/// "perform Choose xs" — returns all values as separate branches
fn nd_choose<A: Clone>(xs: Vec<A>) -> Vec<A> {
    xs
}

/// "guard" — keep only branches where the predicate holds
fn nd_guard(cond: bool) -> Vec<()> {
    if cond { vec![()] } else { vec![] }
}

fn main() {
    // ── Logging handler demo ───────────────────────────────────────────
    let program1 = {
        let x = 42_i32;
        log_bind(
            log_msg("starting", log_done(())),
            move |_| {
                log_bind(
                    log_msg(format!("x = {}", x), log_done(())),
                    move |_| {
                        log_bind(
                            log_msg("done", log_done(())),
                            move |_| log_done(x * 2),
                        )
                    },
                )
            },
        )
    };
    let (result, logs) = collect_logs(program1);
    println!("result={}, logs=[{}]", result, logs.join("; "));

    println!();

    // ── Nondeterminism via list monad ──────────────────────────────────
    // program2: choose x from [1,2,3], choose y from [10,20], return x+y
    let results = nd_bind(nd_choose(vec![1, 2, 3]), |x| {
        nd_bind(nd_choose(vec![10, 20]), move |y| nd_return(x + y))
    });
    let strs: Vec<String> = results.iter().map(|n| n.to_string()).collect();
    println!("all results: [{}]", strs.join("; "));

    println!();

    // ── Nondeterminism: filter (guard) ────────────────────────────────
    // Find pairs (a,b) where a+b == 5, a in [1..4], b in [1..4]
    let pairs = nd_bind(nd_choose(vec![1, 2, 3, 4]), |a| {
        nd_bind(nd_choose(vec![1, 2, 3, 4]), move |b| {
            nd_bind(nd_guard(a + b == 5), move |_| nd_return((a, b)))
        })
    });
    println!("pairs summing to 5: {:?}", pairs);

    println!();

    // ── All Pythagorean triples ≤ 10 ─────────────────────────────────
    let triples = nd_bind(nd_choose((1..=10).collect()), |a| {
        nd_bind(nd_choose((a..=10).collect()), move |b| {
            nd_bind(nd_choose((b..=10).collect()), move |c| {
                nd_bind(nd_guard(a * a + b * b == c * c), move |_| nd_return((a, b, c)))
            })
        })
    });
    println!("Pythagorean triples ≤10: {:?}", &triples[..triples.len().min(5)]);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_collect_logs_captures_messages() {
        let prog = log_bind(
            log_msg("msg1", log_done(())),
            |_| log_bind(log_msg("msg2", log_done(())), |_| log_done(99_i32)),
        );
        let (val, logs) = collect_logs(prog);
        assert_eq!(val, 99);
        assert_eq!(logs, vec!["msg1", "msg2"]);
    }

    #[test]
    fn test_nondeterminism_all_combinations() {
        let results = nd_bind(nd_choose(vec![1, 2, 3]), |x| {
            nd_bind(nd_choose(vec![10, 20]), move |y| nd_return(x + y))
        });
        assert_eq!(results.len(), 6);
        assert!(results.contains(&11)); // 1+10
        assert!(results.contains(&23)); // 3+20
    }

    #[test]
    fn test_guard_filters_correctly() {
        let pairs = nd_bind(nd_choose(vec![1, 2, 3, 4]), |a| {
            nd_bind(nd_choose(vec![1, 2, 3, 4]), move |b| {
                nd_bind(nd_guard(a + b == 5), move |_| nd_return((a, b)))
            })
        });
        assert_eq!(pairs.len(), 4); // (1,4),(2,3),(3,2),(4,1)
        assert!(pairs.contains(&(1, 4)));
        assert!(pairs.contains(&(4, 1)));
    }

    #[test]
    fn test_log_preserves_order() {
        let prog = log_bind(
            log_msg("first", log_done(())),
            |_| log_bind(
                log_msg("second", log_done(())),
                |_| log_bind(
                    log_msg("third", log_done(())),
                    |_| log_done(0_i32),
                ),
            ),
        );
        let (_, logs) = collect_logs(prog);
        assert_eq!(logs, vec!["first", "second", "third"]);
    }
}
(* Effect handlers: intercept performed effects and decide how to resume.
   The handler has access to the delimited continuation 'k'. *)

(* A logging effect *)
effect Log : string -> unit

(* A nondeterminism effect: choose one of two values *)
effect Choose : 'a list -> 'a

(* Run with logging to a buffer *)
let collect_logs program =
  let log = ref [] in
  let result = match program () with
    | v -> v
    | effect (Log msg) k ->
      log := msg :: !log;
      continue k ()
  in
  (result, List.rev !log)

(* Run nondeterministically: return ALL results *)
let run_all program =
  match program () with
  | v -> [v]
  | effect (Choose xs) k ->
    List.concat_map (fun x -> continue k x) xs

let () =
  (* Logging handler *)
  let program1 () =
    perform (Log "starting");
    let x = 42 in
    perform (Log (Printf.sprintf "x = %d" x));
    perform (Log "done");
    x * 2
  in
  let (result, logs) = collect_logs program1 in
  Printf.printf "result=%d, logs=[%s]\n" result (String.concat "; " logs);

  (* Nondeterminism handler *)
  let program2 () =
    let x = perform (Choose [1; 2; 3]) in
    let y = perform (Choose [10; 20]) in
    x + y
  in
  let results = run_all program2 in
  Printf.printf "all results: [%s]\n" (String.concat "; " (List.map string_of_int results))