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
- Testable I/O โ programs that interact with users, files, or networks can be fully unit-tested by running them against fake interpreters; no mocking frameworks needed.
- Logging/auditing interpreters โ wrap the interpreter to record every instruction executed; replay bugs by re-running the exact instruction sequence.
- Game engine command queues โ a frame's worth of game actions as an operational monad program; the interpreter applies them to the game state, or serializes them for multiplayer sync.
Key Differences
| Concept | OCaml | Rust | |
|---|---|---|---|
| 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 results | GADT: `Read`'s continuation receives `string`, guaranteed statically | Rust: `ReadResult(String)` unwrapped at runtime with `panic!` if wrong variant | |
| Bind | Clean recursion with locally abstract types | `Box<dyn FnOnce>` with `'static` bounds โ heavier but equivalent | |
| Extensibility | Add a new `type _ instr` constructor; all match arms catch it | Add new `Instr` variant + `InstrResult` variant; update interpreter `match` | |
| Real-world analogy | Exactly the pattern used in Haskell's `operational` library | Matches 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