Refs
Saga values are immutable by default. When you need controlled mutable state,
the Ref effect provides typed mutable cells with swappable storage backends.
Because mutation is an effect, the type system tracks it and you choose the
implementation at the handler level.
The Ref Effect
import Std.Ref (MutRef, Ref, beam_ref)
fun count_evens : List Int -> Int needs {Ref}
count_evens xs = {
let counter = new! 0
List.iter (fun x ->
if x % 2 == 0 then {
let _ = modify! counter (fun n -> n + 1)
()
} else ()
) xs
get! counter
}
main () = {
let result = count_evens [1, 2, 3, 4, 5, 6] with beam_ref
dbg $"evens: {result}"
}The four operations are:
| Operation | Type | Description |
|---|---|---|
new! | a -> MutRef a | Create a new ref with an initial value |
get! | MutRef a -> a | Read the current value |
set! | MutRef a -> a -> Unit | Overwrite the value |
modify! | MutRef a -> (a -> a) -> a | Read, apply a function, write back, return the new value |
Handlers
Two handlers ship with the standard library:
beam_ref is backed by the process dictionary. Fast, requires no setup,
and scoped to the current process. Refs are cleaned up automatically when the
process exits. This is the default choice for single-process state.
ets_ref is backed by an ETS table. Refs are stored in shared memory,
accessible across processes. Use this when multiple actors need to read or
write the same ref.
import Std.Ref (beam_ref, ets_ref)
# Process-local, fast
run_app () with beam_ref
# Shared across processes
run_app () with ets_refSame code, different handler, different storage backend.
Example: Memoized Fibonacci
A ref holding a Dict makes a simple memoization cache:
import Std.Ref (MutRef, Ref, beam_ref)
fun fib_memo : MutRef (Dict Int Int) -> Int -> Int needs {Ref}
fib_memo cache n = {
let memo = get! cache
case Dict.get n memo {
Just result -> result
Nothing -> {
let result =
if n <= 1 then n
else fib_memo cache (n - 1) + fib_memo cache (n - 2)
let _ = modify! cache (fun m -> Dict.put n result m)
result
}
}
}
fun fibonacci : Int -> Int needs {Ref}
fibonacci n = {
let cache = new! (Dict.new () : Dict Int Int)
fib_memo cache n
}
main () = {
let result = fibonacci 30 with beam_ref
dbg $"fib(30) = {result}"
}Shared Refs Across Processes
With ets_ref, multiple actors can access the same ref. Here, worker processes
each increment a shared counter:
import Std.Actor (Process, Actor, beam_actor)
import Std.Ref (MutRef, Ref, ets_ref)
fun worker : MutRef Int -> Int -> Pid Done -> Unit needs {Ref, Process}
worker counter n parent = {
worker_loop counter n
send! parent Done
}
fun worker_loop : MutRef Int -> Int -> Unit needs {Ref}
worker_loop _ 0 = ()
worker_loop counter n = {
let _ = modify! counter (fun x -> x + 1)
worker_loop counter (n - 1)
}
main () = {
let counter = new! 0
let me = self! ()
let _ = spawn! (fun () -> worker counter 100 me)
receive { Done -> () }
let _ = spawn! (fun () -> worker counter 100 me)
receive { Done -> () }
let total = get! counter
dbg $"total: {total}"
} with {ets_ref, beam_actor}Note that ets_ref does not provide atomicity. For concurrent read-modify-write
operations that need mutual exclusion, use AtomicRef from Std.AtomicRef,
which adds a lock server for safe cross-process mutation.
When to Use Refs vs Recursion
Functional Saga code typically threads state through recursive function calls:
fun loop : Int -> Unit needs {Actor Msg}
loop count = receive {
Increment -> loop (count + 1)
Get caller -> {
send! caller count
loop count
}
}This is the idiomatic approach for actor state, and you should prefer it when the state naturally flows through a recursive loop.
Refs are useful when:
- You need a mutable accumulator inside an otherwise non-recursive function
(like
count_evensabove) - Multiple parts of a computation need to read and write the same cell without passing it through every function call
- You need a cache (like the memoization example)
- Multiple processes need shared mutable state (with
ets_reforatomic_ref)