9. Reader, State and Writer¶
Some computations need configuration, an evolving state or accumulated output as well as their main result. Reader, State and Writer describe these needs as values that you can compose with the algebra operations from chapter 8.
| Effect | What passes between steps | How to get the result |
|---|---|---|
Reader[R] |
A shared environment of type R |
run borrows the environment and returns the payload |
State[S] |
A state of type S, updated by each step |
run consumes the initial state and returns (payload, final_state) |
Writer[W] |
Output combined by the monoid W |
run_writer returns (log, payload) |
Reader and State are deferred: constructing a computation does not execute its
steps. Writer over the default IdentityFamily combines its values eagerly;
run_writer unwraps the result. Writer over a deferred base follows that base's
evaluation rules.
Read configuration with Reader¶
A Reader computation uses the same environment throughout a run. This example prices three items from a configuration containing a unit price and shipping charge.
"""Read pricing configuration with Reader and scope a change with local."""
from fp.algebra import map
from fp.effects import Reader, ask, local, run
from std.testing import assert_equal
@fieldwise_init
struct TutorialPricing(Copyable):
var unit_price: Int
var shipping: Int
comptime PricingReader = Reader[TutorialPricing]
def free_shipping(pricing: TutorialPricing) -> TutorialPricing:
return TutorialPricing(pricing.unit_price, 0)
def main() raises:
var pricing = TutorialPricing(12, 5)
var calls = 0
def quote(var settings: TutorialPricing) {mut calls} -> Int:
calls += 1
return settings.unit_price * 3 + settings.shipping
var standard = map[PricingReader](quote, ask[PricingReader]())
assert_equal(calls, 0)
print("before run: calls =", calls)
var total = run[PricingReader](standard^, pricing)
assert_equal(total, 41)
assert_equal(calls, 1)
print("standard total:", total)
var quoted = map[PricingReader](quote, ask[PricingReader]())
var promotion = local[PricingReader](free_shipping, quoted^)
var discounted = run[PricingReader](promotion^, pricing)
assert_equal(discounted, 36)
assert_equal(pricing.shipping, 5)
assert_equal(calls, 2)
print("free shipping total:", discounted, "; original shipping:", pricing.shipping)
print("after both runs: calls =", calls)
Run from the repository root pixi run mojo run -I src docs/examples/effects_reader.mojo
Output
before run: calls = 0
standard total: 41
free shipping total: 36 ; original shipping: 5
after both runs: calls = 2
PricingReader is an alias for Reader[TutorialPricing]. ask produces a
computation that copies the environment into its payload, and map transforms
that payload with quote. Because ask copies, the configuration implements
Copyable. Reader's run itself only borrows the environment.
Building standard leaves calls at zero. Running it with a unit price of 12
and shipping of 5 calls quote once and returns 12 * 3 + 5 = 41.
local runs a computation with a temporary environment. free_shipping keeps
the unit price and changes shipping to zero, so the promotional quote is 36.
The original configuration still has shipping of 5. The temporary environment
exists only for that run; local rejects a base whose result would keep it.
Keep borrowed callbacks alive until the run¶
map[PricingReader](quote, ...) borrows the native closure. The computation
must run while quote is alive and unmoved. Its captured calls variable must
also remain alive and stable. In the example, both runs finish in the scope
that owns the closure and counter.
To store a native callback by value, adapt it with as_unary; use
as_unary(f^) when transferring a callback with owned captures. The computation
then owns that callable value. Any references the callable captures still keep
their own origin restrictions. Owning the callable does not extend the life of
the values it borrows. These are caller obligations; the pinned compiler does
not reject every invalid owner reassignment.
The same rules apply to native callbacks passed to flat_map and traverse
over deferred Reader or State instances. See
ownership and errors for the shared conventions.
Pass an evolving counter through State¶
State separates the value a step returns from the state passed to the next step. Reading a counter to format a ticket need not change that counter; allocating the next number does.
"""Read, update and reset a counter through State computations."""
from fp.algebra import map, flat_map
from fp.effects import State, get, modify, put, run
from std.testing import assert_equal
comptime CounterState = State[Int]
def ticket(counter: Int) -> String:
return "ticket " + String(counter)
def increment(counter: Int) -> Int:
return counter + 1
def read_counter(var unused: NoneType) -> type_of(get[CounterState]()):
return get[CounterState]()
def main() raises:
var label = map[CounterState](ticket, get[CounterState]())
var described = run[CounterState](label^, 10)
assert_equal(described[0], "ticket 10")
assert_equal(described[1], 10)
print("read:", described[0], "; state =", described[1])
var advance = modify[CounterState](increment)
var next_ticket = flat_map[CounterState](read_counter, advance^)
var advanced = run[CounterState](next_ticket^, 10)
assert_equal(advanced[0], 11)
assert_equal(advanced[1], 11)
print("update: value =", advanced[0], "; state =", advanced[1])
var reset = flat_map[CounterState](read_counter, put[CounterState](0))
var cleared = run[CounterState](reset^, 10)
assert_equal(cleared[0], 0)
assert_equal(cleared[1], 0)
print("reset: value =", cleared[0], "; state =", cleared[1])
Run from the repository root pixi run mojo run -I src docs/examples/effects_state.mojo
Output
read: ticket 10 ; state = 10
update: value = 11 ; state = 11
reset: value = 0 ; state = 0
get reads a copy of the current state. Mapping ticket over it returns the
payload "ticket 10" and leaves the state at 10. run therefore returns
("ticket 10", 10).
modify(increment) changes the state and returns None. flat_map then calls
read_counter, whose get sees the updated value. Starting from 10, the
computation returns (11, 11). The helper's return annotation,
type_of(get[CounterState]()), names the concrete deferred computation it
returns, rather than the Int that a later run will produce.
put(0) replaces the state. Sequencing another get after it returns (0, 0).
Each call to run in this example starts a separate computation from state 10;
there is no global counter. To continue from a previous run, pass its final
state as the next run's initial state.
State's run consumes both the computation and the initial state. An Int
can be copied implicitly; move a state with owned resources using ^.
Accumulate output with Writer¶
Writer pairs a payload with output that can be combined. StringMonoid uses
the empty string as its identity and concatenation as its combination, so logs
are appended in execution order.
"""Accumulate a pricing log with Writer, then inspect and format it."""
from fp.algebra import flat_map, StringMonoid
from fp.effects import Writer, writer, tell, listen, censor, run_writer
from std.testing import assert_equal
comptime PricingWriter = Writer[StringMonoid]
def add_fee(amount: Int) -> type_of(writer[PricingWriter, Int](Tuple(String(), 0))):
return writer[PricingWriter, Int](Tuple(String("fee +2"), amount + 2))
def bracket(var log: String) -> String:
return "[" + log + "]"
def main() raises:
var notice = run_writer[PricingWriter](tell[PricingWriter]("ready"))
assert_equal(notice[0], "ready")
comptime assert type_of(notice[1]) == NoneType
print("notice:", notice[0])
var initial = writer[PricingWriter, Int](Tuple(String("start; "), 3))
var charged = flat_map[PricingWriter](add_fee, initial^)
var observed = listen[PricingWriter](charged^)
var formatted = censor[PricingWriter](bracket, observed^)
var output = run_writer[PricingWriter](formatted^)
assert_equal(output[0], "[start; fee +2]")
assert_equal(output[1][0], 5)
assert_equal(output[1][1], "start; fee +2")
print("value:", output[1][0])
print("log:", output[0])
print("log copied by listen:", output[1][1])
Run from the repository root pixi run mojo run -I src docs/examples/effects_writer.mojo
Output
notice: ready
value: 5
log: [start; fee +2]
log copied by listen: start; fee +2
tell("ready") records a log with a None payload. It does not print anything;
the example unwraps the value with run_writer and prints the log itself.
writer constructs a Writer value from (log, payload). The initial value has
log "start; " and payload 3. flat_map(add_fee, ...) passes 3 to add_fee,
which returns payload 5 and log "fee +2". Writer combines the logs into
"start; fee +2".
listen keeps that log and pairs the payload with a copy of it. censor then
formats the outer log with brackets. The final result is:
("[start; fee +2]", (5, "start; fee +2"))
The log copied by listen records the text before censor changed the outer
log. Copying requires a Copyable log type. ListMonoid[T] is another built-in
choice when the output should be a list of records rather than text.
Combine effects with monad transformers¶
A monad transformer adds an effect to a base Monad. StateT[M, S] adds state
to M; ResultT[M, E] adds a stored error. State[S] is shorthand for
StateT[IdentityFamily, S], with analogous definitions for Reader and Writer.
Use lift to embed an existing base computation in a transformer layer.
The order of layers determines the shape of the result and what survives a failure:
"""Transformer order decides whether a stored failure returns the updated state."""
from fp.algebra import pure, flat_map
from fp.effects import State, StateT, ResultT, run, lift, put
from fp.algebra import ResultFamily
from fp.data import Result, Err
from std.testing import assert_equal
comptime DomainResult=Result[Int,String]
comptime Keep=ResultT[State[Int],String]
comptime Discard=StateT[ResultFamily[String],Int]
def failure()->DomainResult:
return Err(String("rejected"))
# Each adapter places the same domain failure at its stack's base boundary.
def fail_keep(var unused:NoneType)->type_of(pure[State[Int]](failure())):
return pure[State[Int]](failure())
def fail_discard(var unused:NoneType)->type_of(lift[Discard](failure())):
return lift[Discard](failure())
def main() raises:
var kept=run[State[Int]](flat_map[Keep](fail_keep,lift[Keep](put[State[Int]](15))),10)
comptime assert type_of(kept)==Tuple[DomainResult,Int]
ref result=kept[0]
var state=kept[1]
assert_equal(result.is_err(),True)
assert_equal(state,15)
print("ResultT[State]: failed, returned state =",state)
var discarded=run[Discard](flat_map[Discard](fail_discard,put[Discard](15)),10)
comptime assert type_of(discarded)==Result[Tuple[Int,Int],String]
assert_equal(discarded.is_err(),True)
print("StateT[Result]: failed, no state payload")
Run from the repository root pixi run mojo run -I src docs/examples/state_result.mojo
Output
ResultT[State]: failed, returned state = 15
StateT[Result]: failed, no state payload
Both computations set the state to 15 and then fail with Err("rejected").
ResultT[State[Int], String] returns (Err("rejected"), 15), so the caller can
inspect the state reached before failure. Its outer Result is a value inside
State's computation; run it with run[State[Int]].
StateT[ResultFamily[String], Int] returns only Err("rejected"). A successful
result would hold (payload, state) inside Ok, but an error has neither. Run
this computation with its StateT instance. Neither ordering rolls back external
mutations performed by callbacks.
Handle errors when a computation runs¶
A stored Err belongs to the Result layer. A native error raised by a callback
still propagates as an exception with its original type. For Reader and State,
that happens when run executes the callback, not when map or flat_map
constructs the computation.
On Mojo 1.1, catch a raising deferred computation inside try/except; a plain
raises on the enclosing function cannot propagate its error alias. The
troubleshooting guide describes the
diagnostic. Reusing a computation also requires an explicit copy when its type
supports one; a consumed computation cannot be run again.
For other transformer combinations and their result shapes, see fp.effects. The algebra reference describes the shared operations, and the architecture chapter explains how deferred computations preserve their types, ownership and origins.