๐Ÿฆ€ Functional Rust

188: Operational Monad

Difficulty: โญโญโญโญโญ Level: Expert Describe a program as a sequence of typed instructions, then interpret it separately โ€” like the free monad but with an explicit instruction set that's easier to inspect and extend.

The Problem This Solves

The free monad (example 187) is powerful but abstract โ€” the instruction type is tangled with the continuation structure. When you have a DSL with multiple operation types, the free monad requires wrapping and unwrapping multiple layers of functors. Adding a new operation means updating the functor type, the `bind` implementation, and every interpreter. The operational monad separates concerns more cleanly. The instruction set is just an enum โ€” `Read`, `Write(String)`. A program sequences these instructions: `Instr(Read, continuation) | Return(value)`. The instruction says what to do; the continuation says what to do with the result. Interpreters handle each instruction case independently, making them easy to write, test, and swap. This pattern is how you build testable I/O in functional style. Your `hello_program()` function never touches stdin or stdout โ€” it builds a data structure describing the interaction. Tests provide a list of fake inputs; production uses real I/O. The program itself is completely pure; all effects live in the interpreter.

The Intuition

A theatre script. The script says `ACTOR: "What is your name?"` then `PAUSE for audience response` then `ACTOR: "Hello, [response]!"`. The script doesn't make any sounds โ€” it's just text. A director interprets the script: assigns actors, specifies the setting, decides whether to use real microphones or audio playback. Two different directors, same script, completely different productions. In code: `prog_read(|name| prog_write(format!("Hello, {}!", name), prog_return(name)))` is the script. `run_pure(&["Alice"], prog)` is one director (uses a list for input, collects output to a buffer). `run_io(prog)` would be another director (uses real stdin/stdout). The script โ€” the program โ€” is pure data.

How It Works in Rust

// The instruction set โ€” what operations exist
enum Instr {
 Read,           // "give me a line of input"
 Write(String),  // "output this string"
}

// A program: either done, or one instruction + what to do with its result
enum Prog<A> {
 Return(A),
 Instr(Instr, Box<dyn FnOnce(InstrResult) -> Prog<A>>),
 //     ^^^^                  ^^^^^^^^^^
 //     what to do            what the instruction "returns" to us
}

// Each instruction has a return type โ€” this is where OCaml's GADT shines
enum InstrResult {
 ReadResult(String),   // Read returns a String
 WriteResult,          // Write returns ()
}

// Smart constructors โ€” the API for building programs
fn prog_read<A: 'static, F: FnOnce(String) -> Prog<A> + 'static>(f: F) -> Prog<A> {
 Prog::Instr(
     Instr::Read,
     Box::new(move |r| match r {
         InstrResult::ReadResult(s) => f(s),   // unwrap the result, pass to continuation
         _ => panic!("type mismatch"),          // in OCaml GADTs, this case doesn't exist
     }),
 )
}

fn prog_write<A: 'static>(s: impl Into<String>, next: Prog<A>) -> Prog<A> {
 let s = s.into();
 Prog::Instr(Instr::Write(s), Box::new(move |_| next))
}

// A program description โ€” pure data, no I/O:
fn hello_program() -> Prog<String> {
 bind(prog_write("Enter name:", prog_return(())), |_| {
     prog_read(|name| {
         let msg = format!("Hello, {}!", name);
         bind(prog_write(msg, prog_return(())), move |_| prog_return(name))
     })
 })
}

// Pure interpreter โ€” no real I/O, fully testable:
fn run_pure<A>(inputs: &[&str], prog: Prog<A>) -> (A, String) {
 let mut buf = String::new();
 let mut inputs: VecDeque<String> = inputs.iter().map(|s| s.to_string()).collect();

 fn go<A>(prog: Prog<A>, inputs: &mut VecDeque<String>, buf: &mut String) -> A {
     match prog {
         Prog::Return(x) => x,
         Prog::Instr(Instr::Read, cont) => {
             let line = inputs.pop_front().unwrap_or_default();
             go(cont(InstrResult::ReadResult(line)), inputs, buf)  // feed fake input
         }
         Prog::Instr(Instr::Write(s), cont) => {
             buf.push_str(&s); buf.push('\n');
             go(cont(InstrResult::WriteResult), inputs, buf)  // collect output
         }
     }
 }
 (go(prog, &mut inputs, &mut buf), buf)
}

// Test โ€” no stdin/stdout involved:
let (name, output) = run_pure(&["Alice"], hello_program());
assert_eq!(name, "Alice");
assert!(output.contains("Hello, Alice!"));

What This Unlocks

Key Differences

ConceptOCamlRust
Instruction typing`type _ instr = Read : string instr \Write : string -> unit instr` โ€” GADT gives each instruction its own return type`enum Instr` + `enum InstrResult` โ€” two enums with manual matching; no compile-time per-instruction return type
Type safety of resultsGADT: `Read`'s continuation receives `string`, guaranteed staticallyRust: `ReadResult(String)` unwrapped at runtime with `panic!` if wrong variant
BindClean recursion with locally abstract types`Box<dyn FnOnce>` with `'static` bounds โ€” heavier but equivalent
ExtensibilityAdd a new `type _ instr` constructor; all match arms catch itAdd new `Instr` variant + `InstrResult` variant; update interpreter `match`
Real-world analogyExactly the pattern used in Haskell's `operational` libraryMatches the "free algebra" pattern used in Elm's `Cmd` type
// Operational Monad โ€” Instructions and Programs
//
// Similar to the free monad but more explicit: the instruction set is a
// separate enum (the "operation"), and programs sequence instructions with
// continuations.  The interpreter walks the program tree.
//
// DSL: a simple IO language with Read (get a line) and Write (emit a line).

// โ”€โ”€ Instruction set โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

enum Instr {
    Read,
    Write(String),
}

// โ”€โ”€ Programs: an instruction followed by a continuation, or a return value โ”€โ”€

enum Prog<A> {
    Return(A),
    Instr(Instr, Box<dyn FnOnce(InstrResult) -> Prog<A>>),
}

// Instruction results (what each instruction "returns" to the continuation)
enum InstrResult {
    ReadResult(String),
    WriteResult,
}

// โ”€โ”€ Smart constructors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fn prog_return<A>(x: A) -> Prog<A> {
    Prog::Return(x)
}

fn prog_read<A: 'static, F: FnOnce(String) -> Prog<A> + 'static>(f: F) -> Prog<A> {
    Prog::Instr(
        Instr::Read,
        Box::new(move |r| match r {
            InstrResult::ReadResult(s) => f(s),
            _ => panic!("expected ReadResult"),
        }),
    )
}

fn prog_write<A: 'static>(s: impl Into<String>, next: Prog<A>) -> Prog<A> {
    let s = s.into();
    Prog::Instr(
        Instr::Write(s),
        Box::new(move |r| match r {
            InstrResult::WriteResult => next,
            _ => panic!("expected WriteResult"),
        }),
    )
}

// โ”€โ”€ Monadic bind โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fn bind<A: 'static, B: 'static, F: FnOnce(A) -> Prog<B> + 'static>(
    m: Prog<A>,
    f: F,
) -> Prog<B> {
    match m {
        Prog::Return(x) => f(x),
        Prog::Instr(instr, cont) => {
            Prog::Instr(instr, Box::new(move |r| bind(cont(r), f)))
        }
    }
}

// โ”€โ”€ Pure interpreter: list as stdin, buffer as stdout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fn run_pure<A>(inputs: &[&str], prog: Prog<A>) -> (A, String) {
    let mut buf = String::new();
    let mut inputs: std::collections::VecDeque<String> =
        inputs.iter().map(|s| s.to_string()).collect();

    fn go<A>(
        prog: Prog<A>,
        inputs: &mut std::collections::VecDeque<String>,
        buf: &mut String,
    ) -> A {
        match prog {
            Prog::Return(x) => x,
            Prog::Instr(Instr::Read, cont) => {
                let line = inputs.pop_front().unwrap_or_default();
                go(cont(InstrResult::ReadResult(line)), inputs, buf)
            }
            Prog::Instr(Instr::Write(s), cont) => {
                buf.push_str(&s);
                buf.push('\n');
                go(cont(InstrResult::WriteResult), inputs, buf)
            }
        }
    }

    let result = go(prog, &mut inputs, &mut buf);
    (result, buf)
}

// โ”€โ”€ Example programs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fn hello_program() -> Prog<String> {
    bind(prog_write("Enter name:", prog_return(())), |_| {
        prog_read(move |name| {
            let msg = format!("Hello, {}!", name);
            bind(prog_write(msg, prog_return(())), move |_| prog_return(name))
        })
    })
}

fn echo_twice_program() -> Prog<()> {
    prog_read(|line| {
        let out = format!("Echo: {}", line);
        bind(prog_write(out.clone(), prog_return(())), move |_| {
            prog_write(out, prog_return(()))
        })
    })
}

fn main() {
    // โ”€โ”€ Hello program โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    let prog = hello_program();
    let (result, output) = run_pure(&["Alice"], prog);
    println!("Output:\n{}", output.trim());
    println!("Result: {}", result);

    println!();

    // โ”€โ”€ Echo twice โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    let prog2 = echo_twice_program();
    let (_, output2) = run_pure(&["Rust is great"], prog2);
    println!("Echo output:\n{}", output2.trim());
}

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

    #[test]
    fn test_hello_program_output() {
        let prog = hello_program();
        let (result, output) = run_pure(&["Bob"], prog);
        assert_eq!(result, "Bob");
        assert!(output.contains("Hello, Bob!"));
        assert!(output.contains("Enter name:"));
    }

    #[test]
    fn test_echo_twice_output() {
        let prog = echo_twice_program();
        let (_, output) = run_pure(&["hello"], prog);
        // "Echo: hello" appears twice
        let count = output.matches("Echo: hello").count();
        assert_eq!(count, 2);
    }

    #[test]
    fn test_pure_interpreter_no_real_io() {
        // The interpreter uses a list; no stdin/stdout touched
        let prog = prog_read(|line| prog_return(line.len()));
        let (len, _) = run_pure(&["abcde"], prog);
        assert_eq!(len, 5);
    }

    #[test]
    fn test_bind_chains_correctly() {
        let prog = bind(prog_write("a", prog_return(())), |_| {
            bind(prog_write("b", prog_return(())), |_| prog_return(42i32))
        });
        let (val, out) = run_pure(&[], prog);
        assert_eq!(val, 42);
        assert!(out.contains('a') && out.contains('b'));
    }
}
(* Operational monad: instructions are a GADT; programs build the sequence.
   Similar to free monad but more explicit about the instruction set. *)

(* Instruction set for a simple IO DSL *)
type _ instr =
  | Read  : string instr
  | Write : string -> unit instr

(* Programs: sequence of instructions *)
type _ prog =
  | Return : 'a -> 'a prog
  | Instr  : 'a instr * ('a -> 'b prog) -> 'b prog

let return x     = Return x
let read ()      = Instr (Read,    return)
let write s      = Instr (Write s, fun () -> Return ())

let rec bind m f = match m with
  | Return x         -> f x
  | Instr (i, cont)  -> Instr (i, fun x -> bind (cont x) f)

(* Pure interpreter using a list as stdin *)
let run_pure inputs prog =
  let buf = Buffer.create 64 in
  let inputs = ref inputs in
  let rec go : type a. a prog -> a = function
    | Return x -> x
    | Instr (Read, cont) ->
      let line = List.hd !inputs in
      inputs := List.tl !inputs;
      go (cont line)
    | Instr (Write s, cont) ->
      Buffer.add_string buf (s ^ "\n");
      go (cont ())
  in
  (go prog, Buffer.contents buf)

let () =
  let prog =
    bind (write "Enter name:") (fun () ->
    bind (read ())              (fun name ->
    bind (write ("Hello, " ^ name ^ "!")) (fun () ->
    return name)))
  in
  let (result, output) = run_pure ["Alice"] prog in
  Printf.printf "Output:\n%s" output;
  Printf.printf "Result: %s\n" result