Example 1079: Writer Monad — Logging Computation
Difficulty: ⭐⭐⭐ Category: Monadic Patterns OCaml Source: https://cs3110.github.io/textbook/chapters/ds/monads.htmlProblem Statement
Implement a Writer monad that accumulates a log of messages alongside a computation. Chain operations that produce both a result and log entries, combining them transparently.Learning Outcomes
- Writer monad as a pattern for structured logging without side effects
- Monadic `bind` in Rust via method chaining vs OCaml's `>>=` operator
- Generic monoid abstraction for the log type
- How Rust's ownership makes `bind` naturally consume the previous state
OCaml Approach
OCaml defines Writer as a record with `value` and `log` fields. The `>>=` operator (bind) applies a function to the value and concatenates the logs. `tell` creates a log-only entry. The pipeline reads naturally with `>>=` and `fun` closures.Rust Approach
Rust implements Writer as a generic struct with `bind` and `map` methods. Method chaining (`.bind(half).bind(...)`) replaces OCaml's `>>=` operator. A generic version parameterized by a `Monoid` trait shows how the pattern generalizes beyond `Vec<String>`.Key Differences
1. Operator overloading: OCaml defines `>>=` easily; Rust uses method chaining instead (operator overloading is possible but less ergonomic for monads) 2. Ownership: Rust's `bind` consumes `self`, making it clear the old Writer is gone. OCaml's bind copies/shares the log via GC 3. Monoid abstraction: OCaml uses `@` (list append) directly; Rust can abstract over the log type with a `Monoid` trait 4. Type inference: OCaml infers everything from usage; Rust sometimes needs explicit type annotations on closures#[derive(Debug, Clone)]
struct Writer<A> {
value: A,
log: Vec<String>,
}
impl<A> Writer<A> {
fn new(value: A) -> Self {
Writer { value, log: Vec::new() }
}
fn bind<B, F>(self, f: F) -> Writer<B>
where F: FnOnce(A) -> Writer<B> {
let mut result = f(self.value);
let mut combined = self.log;
combined.append(&mut result.log);
Writer { value: result.value, log: combined }
}
fn map<B, F>(self, f: F) -> Writer<B>
where F: FnOnce(A) -> B {
Writer { value: f(self.value), log: self.log }
}
}
fn tell(msg: impl Into<String>) -> Writer<()> {
Writer { value: (), log: vec![msg.into()] }
}
fn half(x: i64) -> Writer<i64> {
let result = x / 2;
Writer { value: result, log: vec![format!("halved {x} to {result}")] }
}
fn compute(x: i64) -> Writer<i64> {
Writer::new(x)
.bind(half)
.bind(|n| tell(format!("result is {n}")).map(|()| n))
}
fn main() {
let result = compute(100);
println!("Value: {}", result.value);
for msg in &result.log {
println!(" Log: {msg}");
}
}
/* Output:
Value: 50
Log: halved 100 to 50
Log: result is 50
*/
(* Writer Monad — Logging Computation *)
type 'a writer = { value: 'a; log: string list }
let return x = { value = x; log = [] }
let bind w f =
let w' = f w.value in
{ value = w'.value; log = w.log @ w'.log }
let tell msg = { value = (); log = [msg] }
let ( >>= ) = bind
let half x =
{ value = x / 2; log = [Printf.sprintf "halved %d to %d" x (x / 2)] }
let compute x =
return x >>= fun n ->
half n >>= fun n ->
tell (Printf.sprintf "result is %d" n) >>= fun () ->
return n
let () =
let result = compute 100 in
assert (result.value = 50);
assert (result.log = ["halved 100 to 50"; "result is 50"]);
Printf.printf "Value: %d\n" result.value;
List.iter (Printf.printf " Log: %s\n") result.log;
print_endline "ok"
📊 Detailed Comparison
OCaml vs Rust: Writer Monad — Logging Computation
Side-by-Side Code
OCaml
🐪 Show OCaml equivalent
type 'a writer = { value: 'a; log: string list }
let return x = { value = x; log = [] }
let bind w f =
let w' = f w.value in
{ value = w'.value; log = w.log @ w'.log }
let ( >>= ) = bind
let tell msg = { value = (); log = [msg] }
let half x =
{ value = x / 2; log = [Printf.sprintf "halved %d to %d" x (x / 2)] }
let compute x =
return x >>= fun n ->
half n >>= fun n ->
tell (Printf.sprintf "result is %d" n) >>= fun () ->
return nRust (idiomatic)
pub struct Writer<A> {
pub value: A,
pub log: Vec<String>,
}
impl<A> Writer<A> {
pub fn new(value: A) -> Self { Writer { value, log: Vec::new() } }
pub fn bind<B, F>(self, f: F) -> Writer<B>
where F: FnOnce(A) -> Writer<B> {
let mut result = f(self.value);
let mut combined = self.log;
combined.append(&mut result.log);
Writer { value: result.value, log: combined }
}
}
pub fn compute(x: i64) -> Writer<i64> {
Writer::new(x)
.bind(half)
.bind(|n| tell(format!("result is {n}")).map(|()| n))
}Rust (generic monoid)
pub trait Monoid: Default {
fn combine(self, other: Self) -> Self;
}
pub struct GenericWriter<W, A> {
pub value: A,
pub log: W,
}Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| Writer type | `type 'a writer = { value: 'a; log: string list }` | `struct Writer<A> { value: A, log: Vec<String> }` |
| Return/pure | `val return : 'a -> 'a writer` | `fn new(value: A) -> Writer<A>` |
| Bind | `val bind : 'a writer -> ('a -> 'b writer) -> 'b writer` | `fn bind<B>(self, f: FnOnce(A) -> Writer<B>) -> Writer<B>` |
| Tell | `val tell : string -> unit writer` | `fn tell(msg: impl Into<String>) -> Writer<()>` |
Key Insights
1. OCaml's `>>=` reads like a pipeline — `return x >>= half >>= ...` flows left-to-right. Rust's `.bind(half).bind(...)` achieves the same with method chaining. 2. Rust's `self` consumption is monadic by nature — `bind(self, f)` takes ownership, which mirrors the monad law that each bind transforms the entire computation, not just the value. 3. Log concatenation differs — OCaml uses `@` (list append, O(n)). Rust uses `Vec::append` which is amortized O(1) because it moves the buffer pointer. 4. The Monoid abstraction generalizes the pattern — by parameterizing the log type with a `Monoid` trait, Rust can use `String`, `Vec<T>`, or any accumulator. OCaml achieves this with module functors. 5. Both avoid side effects — the log is part of the return value, not a mutable global. This makes the computation pure and testable.
When to Use Each Style
Use Writer monad when: You need structured, composable logging that's part of the return type — audit trails, computation traces, query plan explanations. Use simple method chaining when: You just need basic logging and the full monadic abstraction is overkill — Rust's `log` crate or `tracing` is often simpler for real applications.