# corophage > Algebraic effects for stable Rust corophage lets you define side effects as plain structs, write business logic that yields those effects, and attach handlers separately. Programs are testable, composable, and run on stable Rust with no macros beyond the optional `#[effect]` and `#[effectful]` attributes. - [Source Code](https://github.com/romac/corophage) - [Crate](https://crates.io/crates/corophage) - [API Documentation](https://docs.rs/corophage) --- ## Getting Started ## Installation Add `corophage` to your `Cargo.toml`: ```toml [dependencies] corophage = "0.3.0" ``` corophage requires **Rust 1.85** or later (stable). ## What are effect handlers? Imagine you are writing a piece of business logic: 1. Log a "starting" message. 2. Read some configuration from a file. 3. Get the current application state. 4. Perform a calculation and update the state. 5. If a condition is met, cancel the entire operation. Traditionally, you'd 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 `yield`ing **effects**. The responsibility of *implementing* these effects 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. ## Your first program ```rust use corophage::prelude::*; // 1. Declare effects #[effect(())] struct Log(String); #[effect(String)] struct FileRead(String); // 2. Write the computation #[effectful(Log, FileRead)] fn my_program() -> String { yield_!(Log("Starting...".into())); let config = yield_!(FileRead("config.toml".into())); config } // 3. Attach handlers and run let result = my_program() .handle(|Log(msg)| { println!("{msg}"); Control::resume(()) }) .handle(|FileRead(f)| { Control::resume(format!("contents of {f}")) }) .run_sync(); assert_eq!(result, Ok("contents of config.toml".to_string())); ``` Let's walk through each step. ### Step 1: Declare effects ```rust #[effect(())] struct Log(String); #[effect(String)] struct FileRead(String); ``` An effect is a plain struct annotated with `#[effect(ResumeType)]`. The struct's fields carry the request data — `Log` carries the message to log, `FileRead` carries the path to read. The attribute argument defines what the handler sends back. `Log` resumes with `()` because logging doesn't produce a value. `FileRead` resumes with a `String` — the file's contents. ### Step 2: Write the computation ```rust #[effectful(Log, FileRead)] fn my_program() -> String { yield_!(Log("Starting...".into())); let config = yield_!(FileRead("config.toml".into())); config } ``` The `#[effectful(Eff1, Eff2, ...)]` macro marks a function as an effectful computation. Inside the function body, use `yield_!(effect)` to perform an effect — this pauses execution, hands the effect to its handler, and resumes with the handler's return value. The computation doesn't know *how* logging or file reading work. It just describes what it needs. ### Step 3: Attach handlers and run ```rust let result = my_program() .handle(|Log(msg)| { println!("{msg}"); Control::resume(()) }) .handle(|FileRead(f)| { Control::resume(format!("contents of {f}")) }) .run_sync(); ``` Calling `my_program()` returns a `Program` — a description of the computation, not the result. Each `.handle()` call attaches a handler for one effect. Handlers can be attached in any order. Once all effects are handled, `.run_sync()` executes the computation and returns a `Result`. If you try to call `.run_sync()` before all effects are handled, you'll get a compile error. ### Manual alternative You can also create programs directly with `Program::new` instead of `#[effectful]`: ```rust type MyEffects = Effects![Log, FileRead]; let program = Program::new(|y: Yielder<'_, MyEffects>| async move { y.yield_(Log("Starting...".into())).await; let config = y.yield_(FileRead("config.toml".into())).await; config }); ``` This is useful when you need more control over the closure (e.g., capturing specific variables by reference). ## Benefits - **Testability**: Swap in mock handlers for testing without touching the real world. - **Modularity**: The core logic is completely decoupled from its execution context. Run the same logic with different handlers for different environments. - **Clarity**: Business logic becomes a pure, high-level description of the steps involved, making it easier to read and reason about. > corophage provides **single-shot** effect handlers: each handler can resume the computation at most once. This is a deliberate design choice that keeps the implementation efficient and compatible with Rust's ownership model. --- ## 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. ## Defining effects with `#[effect]` To define an effect, annotate a struct with `#[effect(ResumeType)]`. The resume type defines what value the computation receives back after the effect is handled. ```rust use corophage::prelude::*; #[effect(())] pub struct Log(pub String); #[effect(String)] pub struct FileRead(pub String); #[effect(Never)] pub struct Cancel; ``` The macro supports lifetimes, generics, named fields, and borrowed resume types: ```rust // Lifetime parameters #[effect(bool)] pub struct Borrow<'a>(pub &'a str); // Generic parameters #[effect(T)] pub struct Generic(pub T); // Named fields #[effect(Vec)] pub struct ReadDir { pub path: String, pub recursive: bool } // The resume type may reference the GAT lifetime 'r #[effect(&'r str)] pub struct Lookup(pub String); ``` ## The `declare_effect!` macro Alternatively, you can use the `declare_effect!` macro for a more concise syntax: ```rust use corophage::prelude::*; declare_effect!(Log(String) -> ()); declare_effect!(FileRead(String) -> String); declare_effect!(Cancel -> Never); ``` The macro supports lifetimes, generics, named fields, and borrowed resume types: ```rust // Lifetime parameters declare_effect!(Borrow<'a>(&'a str) -> bool); // Generic parameters declare_effect!(Generic(T) -> T); // Named fields declare_effect!(ReadDir { path: String, recursive: bool } -> Vec); // The resume type may reference the GAT lifetime 'r declare_effect!(Lookup(String) -> &'r str); ``` ## Effect sets with `Effects!` Effects are grouped into sets using the `Effects!` macro: ```rust type MyEffects = Effects![Log, FileRead, Cancel]; ``` This creates a type-level list (coproduct) of effects. The type system tracks which effects have been handled and prevents you from running a program until all effects have handlers. --- ## Programs A **Program** combines a computation with its effect handlers. This is the primary API for using corophage. ## Creating a program with `#[effectful]` The simplest way to create a program is with the `#[effectful]` attribute macro: ```rust use corophage::prelude::*; #[effect(())] struct Log(String); #[effect(u64)] struct Counter; #[effectful(Log, Counter)] fn my_program() -> u64 { yield_!(Log("hello".into())); let n = yield_!(Counter); n * 2 } ``` The `#[effectful(Eff1, Eff2, ...)]` macro: - Transforms the return type to `Effectful<'_, Effects![Eff1, Eff2, ...], T>` - Wraps the body in `Program::new` - Enables `yield_!(effect)` syntax to perform effects ### Lifetime handling If your effects borrow data, the macro infers the lifetime automatically when the function has exactly one lifetime parameter: ```rust #[effectful(Log<'a>)] fn log_msg<'a>(msg: &'a str) -> () { yield_!(Log(msg)); } ``` With multiple lifetime parameters, specify the effect lifetime explicitly as the first argument: ```rust #[effectful('a, Log<'a>)] fn log_msg<'a, 'b>(msg: &'a str, _other: &'b str) -> () { yield_!(Log(msg)); } ``` ### `Send`-able programs Add `send` to the attribute to create a `Send`-able program (for use with `tokio::spawn`): ```rust #[effectful(Counter, send)] fn my_send_program() -> u64 { yield_!(Counter) } ``` ## Creating a program with `Program::new` You can also create programs manually with `Program::new`, which takes an async closure that receives a `Yielder`: ```rust use corophage::prelude::*; #[effect(())] struct Log(String); #[effect(u64)] struct Counter; type Effs = Effects![Log, Counter]; let program = Program::new(|y: Yielder<'_, Effs>| async move { y.yield_(Log("hello".into())).await; let n = y.yield_(Counter).await; n * 2 }); ``` When you `await` the result of `y.yield_(some_effect)`, the computation pauses, the effect is handled, and the `await` resolves to the value provided by the handler. ## Attaching handlers Handlers are attached one at a time with `.handle()`. Handlers can be attached in any order — the type system tracks which effects remain unhandled. ```rust let result = my_program() .handle(|Log(msg)| { println!("{msg}"); Control::resume(()) }) .handle(|_: Counter| Control::resume(42u64)) .run_sync(); assert_eq!(result, Ok(84)); ``` You can also attach multiple handlers at once with `.handle_all()`: ```rust #[effectful(Counter, Log)] fn my_program() -> u64 { yield_!(Log("start".into())); yield_!(Counter) } let result = my_program() .handle_all(hlist![ |_: Counter| Control::resume(42u64), |Log(msg)| { println!("{msg}"); Control::resume(()) }, ]) .run_sync(); ``` ## Running programs Once all effects are handled, you can run the program: - `.run_sync()` — execute synchronously, returns `Result` - `.run().await` — execute asynchronously - `.run_sync_stateful(&mut state)` — synchronous with shared mutable state - `.run_stateful(&mut state).await` — async with shared mutable state ## Partially-handled programs A partially-handled program is a first-class value you can pass around, store, or extend later: ```rust fn add_logging(program: Program) -> Program { program.handle(|Log(msg)| { println!("{msg}"); Control::resume(()) }) } ``` The compiler enforces at the type level that you can only call `.run_sync()` when all effects have been handled — attempting to run a partially-handled program is a compile error. ## Program composition Programs can invoke other programs via `invoke!()` (or `y.invoke()` with the manual API). The sub-program's effects must be a subset of the outer program's effects — each yielded effect is forwarded to the outer handler automatically. ```rust use corophage::prelude::*; #[effect(&'static str)] struct Ask(&'static str); #[effect(())] struct Print(String); #[effect(())] struct Log(&'static str); #[effectful(Ask, Print)] fn greet() { let name: &str = yield_!(Ask("name?")); yield_!(Print(format!("Hello, {name}!"))); } #[effectful(Ask, Print, Log)] fn main_program() { yield_!(Log("Starting...")); invoke!(greet()); yield_!(Log("Done!")); } let result = main_program() .handle(|_: Ask| Control::resume("world")) .handle(|Print(msg)| { println!("{msg}"); Control::resume(()) }) .handle(|_: Log| Control::resume(())) .run_sync(); assert_eq!(result, Ok(())); ``` With the manual `Program::new` API, use `y.invoke(program).await`: ```rust let result = Program::new(|y: Yielder<'_, Effects![Ask, Print, Log]>| async move { y.yield_(Log("Starting...")).await; y.invoke(greet()).await; y.yield_(Log("Done!")).await; }) .handle(|_: Ask| Control::resume("world")) .handle(|Print(msg)| { println!("{msg}"); Control::resume(()) }) .handle(|_: Log| Control::resume(())) .run_sync(); ``` Sub-programs can be nested — a sub-program can itself invoke other sub-programs. The only requirement is that the inner program's effects are a subset of the outer program's effects. ## `Send`-able programs For use with multi-threaded runtimes like tokio, use `#[effectful(..., send)]` or `Program::new_send`: ```rust #[effectful(Counter, send)] fn my_program() -> u64 { yield_!(Counter) } // Can be spawned on tokio tokio::spawn(async move { let result = my_program() .handle(async |_: Counter| Control::resume(42u64)) .run() .await; }); ``` Or with the manual API: ```rust let program = Program::new_send(|y: Yielder<'_, Effs>| async move { y.yield_(Counter).await }); tokio::spawn(async move { let result = program .handle(async |_: Counter| Control::resume(42u64)) .run() .await; }); ``` --- ## Handlers A **Handler** is a function that implements the logic for a specific effect. It receives the effect instance and returns a `Control`, which tells the runner what to do next. ## Control flow `Control` has two variants: - `Control::resume(value)` — resumes the computation, passing `value` back as the result of `yield_`. The type of `value` must match the effect's `Resume` type. - `Control::cancel()` — aborts the entire computation immediately. The final result will be `Err(Cancelled)`. ## Sync handlers A sync handler is a regular closure: ```rust |Log(msg)| { println!("LOG: {msg}"); Control::resume(()) } ``` ## Async handlers An async handler is an async closure (requires Rust 1.85+): ```rust async |FileRead(file)| { let content = tokio::fs::read_to_string(file).await.unwrap(); Control::resume(content) } ``` ## Named function handlers Handlers can also be named functions. When using stateful handlers, the state is passed as the first argument: ```rust struct AppState { verbose: bool } fn log(s: &mut AppState, Log(msg): Log<'_>) -> Control<()> { if s.verbose { println!("LOG: {msg}"); } Control::resume(()) } fn file_read(_: &mut AppState, FileRead(file): FileRead) -> Control { println!("Reading file: {file}"); Control::resume("file content".to_string()) } ``` ## Cancellation Use `Control::cancel()` when an effect should abort the entire computation: ```rust #[effect(Never)] struct Cancel; // The handler cancels the computation |_: Cancel| Control::cancel() ``` The `Never` resume type indicates this effect can never resume — the handler must always cancel. --- ## Shared State Handlers can share mutable state via `run_sync_stateful` / `run_stateful`. The state is passed as a `&mut S` first argument to every handler. ## Basic example ```rust use corophage::prelude::*; #[effect(u64)] struct Counter; #[effectful(Counter)] fn count_up() -> u64 { let a = yield_!(Counter); let b = yield_!(Counter); a + b } let mut count: u64 = 0; let result = count_up() .handle(|s: &mut u64, _: Counter| { *s += 1; Control::resume(*s) }) .run_sync_stateful(&mut count); assert_eq!(result, Ok(3)); // 1 + 2 assert_eq!(count, 2); // handler was called twice ``` ## Multiple effects with shared state All handlers in a stateful run share the same `&mut S`: ```rust use corophage::prelude::*; use std::marker::PhantomData; #[effect(())] pub struct Log<'a>(pub &'a str); #[derive(Default)] #[effect(S)] pub struct GetState { _marker: PhantomData, } #[effect(())] pub struct SetState(pub S); #[effectful(Log<'static>, GetState, SetState)] fn my_program() -> u64 { yield_!(Log("starting")); let val = yield_!(GetState::default()); yield_!(SetState(val * 2)); yield_!(GetState::default()) } #[derive(Debug)] struct AppState { x: u64 } let mut state = AppState { x: 42 }; let result = my_program() .handle(|_s: &mut AppState, Log(msg)| { println!("{msg}"); Control::resume(()) }) .handle(|s: &mut AppState, _: GetState| Control::resume(s.x)) .handle(|s: &mut AppState, SetState(x)| { s.x = x; Control::resume(()) }) .run_sync_stateful(&mut state); assert_eq!(result, Ok(84)); assert_eq!(state.x, 84); ``` ## Alternatives If your handlers don't need shared state, use `.run_sync()` / `.run()` instead. You can also use `RefCell` or other interior mutability patterns to share state without `run_stateful`. --- ## Advanced Usage ## Borrowing non-`'static` data Effects can borrow data from the local scope by using a non-`'static` lifetime: ```rust use corophage::prelude::*; #[effect(())] struct Log<'a>(pub &'a str); #[effectful(Log<'a>)] fn log_it<'a>(msg: &'a str) -> () { yield_!(Log(msg)); } let msg = String::from("hello from a local string"); let result = log_it(&msg) .handle(|Log(m)| { println!("{m}"); Control::resume(()) }) .run_sync(); assert_eq!(result, Ok(())); ``` ## 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. ```rust use corophage::prelude::*; use std::collections::HashMap; #[effect(&'r str)] struct Lookup<'a> { map: &'a HashMap, key: &'a str, } #[effectful(Lookup<'a>)] fn lookup_config<'a>(map: &'a HashMap) -> String { let host: &str = yield_!(Lookup { map, key: "host" }); let port: &str = yield_!(Lookup { map, key: "port" }); format!("{host}:{port}") } let map = HashMap::from([ ("host".to_string(), "localhost".to_string()), ("port".to_string(), "5432".to_string()), ]); let result = lookup_config(&map) .handle(|Lookup { map, key }| { let value = map.get(key).unwrap(); Control::resume(value.as_str()) }) .run_sync(); assert_eq!(result, Ok("localhost:5432".to_string())); ``` --- ## Performance Benchmarks were run using [Divan](https://github.com/nvzqz/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. While the source-level dispatch uses recursive trait impls over nested `Coproduct::Inl`/`Inr` variants, the compiler monomorphizes and inlines the entire chain into a flat discriminant-based branch — the same code LLVM would emit for a plain `match` on a flat enum. The result is effectively O(1). ## State management | Pattern | Median | |---------|--------| | Stateless (`run`) | 38 ns | | Stateful (`run_stateful`) | 53 ns | | RefCell pattern | 55 ns | Stateful handlers add ~15 ns overhead. RefCell is nearly equivalent to `run_stateful`. ## 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.