Corophage
An effect handler library for Rust
corophage provides a way to separate the description of what your program should do from the implementation of how it gets done. This allows you to write clean, testable, and composable business logic.
Usage
Add corophage to your Cargo.toml:
[]
= "0.1.0"
What are effect handlers?
Imagine you are writing a piece of business logic:
- Log a "starting" message.
- Read some configuration from a file.
- Get the current application state.
- Perform a calculation and update the state.
- If a condition is met, cancel the entire operation.
Traditionally, you might write a function that takes a logger, a file system handle, and a mutable reference to the state. This function would be tightly coupled to these specific implementations.
With effect handlers, your business logic function does none of these things directly. Instead, it describes the side effects it needs to perform by yielding effects.
// This function describes WHAT to do, not HOW.
, >
The responsibility of implementing these effects (e.g., actually printing to the console, reading from the disk, or managing state) is given to a set of handlers. You provide these handlers to a runner, which executes your logic and calls the appropriate handler whenever an effect is yielded.
This separation provides powerful benefits:
- Testability: For tests, you can provide mock handlers that simulate logging, file I/O, or state management without touching the real world.
- Modularity: The core logic is completely decoupled from its execution context. You can run the same logic with different handlers for different environments (e.g., production vs. testing, CLI vs. GUI).
- Clarity: The business logic code becomes a pure, high-level description of the steps involved, making it easier to read and reason about.
Core concepts
corophage is built around a few key concepts: Effects, Computations, and Handlers.
1. Effects
An Effect is a struct that represents a request for a side effect. It's a message from your computation to the outside world. To define an effect, you implement the Effect trait.
The most important part of the Effect trait is the associated type Resume<'r> (a generic associated type), which defines the type of the value that the computation will receive back after the effect is handled.
use ;
// An effect to request logging a message.
// It doesn't need any data back, so we resume with `()`.
;
// An effect to request reading a file.
// It expects the file's contents back, so we resume with `String`.
;
// An effect that cancels the computation.
// It will never resume, so we use the special `Never` type.
;
2. Computations (Co and CoSend)
A Computation is a piece of logic that can yield effects. It is represented by the Co<'a, E, T> type, where 'a is the lifetime bound for the effects, E is a list of possible effects, and T is the final return value.
The lifetime 'a controls how long the effects (and any data they borrow) must live. Use 'static when your effects own all their data, or a shorter lifetime when effects need to borrow from the local scope.
Both Co and CoSend are type aliases for GenericCo<'a, Effs, Return, L>, parameterized by a Locality marker type:
Co(usesLocal) - the default, notSend. Use this when your coroutine doesn't need to cross thread boundaries.CoSend(usesSendable) -Send-able. Use this when you need to spawn the coroutine on a multi-threaded executor liketokio::spawn.
You create a computation with Co::new (or CoSend::new), which takes an async closure. This closure receives a Yielder argument, which you use to perform effects with yielder.yield_(...).
When you await the result of yielder.yield_(some_effect), the computation pauses, the effect is handled by its corresponding handler, and the await resolves to the value provided by the handler (which must match the effect's Resume<'r> type).
use ;
// A type alias for all the effects our computation can perform.
// The `Effects!` macro creates a type-level list of effects.
pub type MyEffects = Effects!;
// This function defines a computation.
Borrowing non-'static data
Effects can borrow data from the local scope by using a non-'static lifetime:
use ;
use hlist;
;
let msg = Stringfrom;
let msg_ref = msg.as_str; // a non-'static reference
// The lifetime on `Co` ties the computation to `msg_ref`'s lifetime.
let co: , > = new;
let result = run;
assert_eq!;
Borrowed resume types
Because Effect::Resume<'r> is a generic associated type (GAT), handlers can resume computations with borrowed data instead of requiring owned values. This is useful when the handler has access to data that the computation only needs to read temporarily.
use *;
use HashMap;
// An effect that looks up a key in a map.
// Thanks to the GAT, the handler can resume with a `&str`
// borrowed from the map, avoiding a clone.
let map = from;
let co: , String> = new;
let result = run;
assert_eq!;
Send-able coroutines with CoSend
By default, Co is not Send, which means it cannot be moved across threads. When you need to spawn a coroutine on a multi-threaded async runtime (e.g., tokio::spawn), use CoSend instead:
use ;
use hlist;
, String>
// CoSend is Send, so it can be spawned on a multi-threaded executor.
let handle = spawn;
Both Co and CoSend work with the same run/run_with functions - the runner is generic over the Locality parameter.
3. Handlers
A Handler is an async function that implements the logic for a specific effect. It receives a mutable reference to a shared State and the Effect instance that was yielded.
The handler must return a CoControl, which tells the runner what to do next:
CoControl::resume(value): Resumes the computation, passingvalueback as the result of theyield_. The type ofvaluemust match the effect'sResumetype.CoControl::cancel(): Aborts the entire computation immediately. The final result of the run will beErr(Cancelled).
use ;
// A handler for the `Log` effect.
async
// A handler for the `FileRead` effect.
async
// A handler for the `Cancel` effect.
async
4. State
As seen above, handlers can be stateful. The run_with function takes a mutable reference to a state object of your choosing. This same state object is passed as the first argument to every handler, allowing them to share and modify state.
[!NOTE] If your handlers do not need shared state, you can instead run them using the
runfunction, which does not require a state parameter.
The example uses GetState and SetState effects to explicitly manage state from within the computation itself.
// The shared state for our handlers.
// Handler for GetState<u64>
async
// Handler for SetState<u64>
async
Putting it all together
To run a computation, you use corophage::run_with. You need three things:
- The
Cocomputation to run. - An initial
State. - A list of handlers, provided in a heterogeneous list (
hlist).
[!IMPORTANT] The order of handlers in the
hlistmust exactly match the order of effects in yourEffects!macro.**
use hlist;
use ;
// 1. Define the computation (see `co()` function above).
// 2. Define and initialize the state.
let mut state = State ;
// 3. Define handlers (see handler functions above).
// 4. Run the computation.
let result = run_with
.await;
// The computation was cancelled by the `Cancel` effect.
assert_eq!;
// The state was modified by the handlers before cancellation.
assert_eq!;
Execution flow
run_withstarts theco()computation.yielder.yield_(Log(...))is called. The computation pauses.run_withfinds theloghandler (2nd in the list) and executes it. "LOG: Hello, world!" is printed. The handler returnsCoControl::resume(()).- The computation resumes.
yielder.yield_(FileRead(...))is called. The computation pauses.run_withfinds thefile_readhandler (3rd in the list) and executes it. It returnsCoControl::resume("file content").- The computation resumes.
textis"file content". yielder.yield_(GetState)is called. The 4th handler runs, returningCoControl::resume(42).- The computation resumes.
stateis42. yielder.yield_(SetState(84))is called. The 5th handler runs, changingstate.xto84and resuming.yielder.yield_(GetState)is called again. The 4th handler runs, now returningCoControl::resume(84).yielder.yield_(Cancel)is called. The computation pauses.run_withfinds thecancelhandler (1st in the list) and executes it. It returnsCoControl::cancel().- The entire execution is aborted.
run_withreturnsErr(Cancelled).
Performance
Benchmarks were run using Divan. Run them with cargo bench.
Coroutine Overhead
| Benchmark | Median | Notes |
|---|---|---|
coroutine_creation |
~7 ns | Just struct initialization |
empty_coroutine |
~30 ns | Full lifecycle with no yields |
single_yield |
~38 ns | One yield/resume cycle |
Coroutine creation is nearly free, and the baseline overhead for running a coroutine is ~30 ns.
Yield Scaling (Sync vs Async)
| Yields | Sync | Async | Overhead |
|---|---|---|---|
| 10 | 131 ns | 178 ns | +36% |
| 100 | 1.0 µs | 1.27 µs | +27% |
| 1000 | 9.5 µs | 11.1 µs | +17% |
Async adds ~30% overhead at small scales, but the gap narrows as yield count increases. Per-yield cost is approximately 9-10 ns for sync and 11 ns for async.
Effect Dispatch Position
| Position | Median |
|---|---|
| First (index 0) | 49 ns |
| Middle (index 2) | 42 ns |
| Last (index 4) | 47 ns |
Dispatch position has negligible impact. The coproduct-based dispatch is effectively O(1).
State Management
| Pattern | Median |
|---|---|
Stateless (run) |
38 ns |
Stateful (run_with) |
53 ns |
| RefCell pattern | 55 ns |
Stateful handlers add ~15 ns overhead. RefCell is nearly equivalent to run_with.
Handler Complexity
| Handler | Median |
|---|---|
Noop (returns ()) |
42 ns |
Allocating (returns String) |
83 ns |
Allocation dominates handler cost. Consider returning references or zero-copy types for performance-critical effects.
Acknowledgments
corophage is heavily inspired by effing-mad, a pioneering algebraic effects library for nightly Rust.
effing-mad demonstrated that algebraic effects and effect handlers are viable in Rust by leveraging coroutines to let effectful functions suspend, pass control to their callers, and resume with results.
While effing-mad requires nightly Rust for its #[coroutine]-based approach, corophage supports stable Rust by leveraging async coroutines (via fauxgen). Big thanks as well to frunk for its coproduct and hlist implementation.
License
Licensed under either of
- Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (http://opensource.org/licenses/MIT)
at your option.
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.