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:
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
Borrowed and generic arguments are captured for the lifetime of the returned program automatically. Elided references, &self receivers, generic parameters, and argument-position impl Trait do not need artificial lifetime annotations or outlives bounds.
#[effectful]
fn length(value: &str) -> usize {
value.len()
}
Generic parameters, argument-position impl Trait, and borrowed receivers work the same way:
#[effectful]
fn stringify<T: ToString>(value: T) -> String {
value.to_string()
}
#[effectful]
fn stringify_impl(value: impl ToString) -> String {
value.to_string()
}
struct Label(String);
impl Label {
#[effectful]
fn length(&self) -> usize {
self.0.len()
}
}
If your effects borrow data, the macro infers the lifetime automatically when the function has exactly one lifetime parameter:
#[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:
#[effectful('a, Log<'a>)]
fn log_msg<'a, 'b>(msg: &'a str, _other: &'b str) -> () {
yield_!(Log(msg));
}
An explicit lifetime can also be the only attribute argument. A named lifetime needs no trailing comma, 'static remains a concrete lifetime, and '_ requests the same inference used when the lifetime is omitted:
#[effectful('a)]
fn named<'a>(value: &'a str) -> usize {
value.len()
}
#[effectful('static)]
fn static_value() -> &'static str {
"static"
}
#[effectful('_)]
fn inferred(value: &str) -> usize {
value.len()
}Send-able programs
Add send to the attribute to create a Send-able program (for use with tokio::spawn):
#[effectful(Counter, send)]
fn my_send_program() -> u64 {
yield_!(Counter)
}Spreading an effects type alias
If you define a reusable set of effects with type MyEffs = Effects![...], you can spread it into the attribute with ...Alias (the same syntax as frunk's Coprod!(...Tail)):
type MyEffs = Effects![Log, Counter];
#[effectful(...MyEffs)]
fn using_alias() -> u64 {
yield_!(Log("hello".into()));
yield_!(Counter)
}
You can also prepend extra inline effects before the spread:
#[effect(&'r str)]
struct GetConfig;
#[effectful(GetConfig, ...MyEffs)]
fn with_extra() -> String {
let cfg = yield_!(GetConfig);
yield_!(Log(cfg.to_string()));
format!("{cfg}: {}", yield_!(Counter))
}
The spread must be the last effect argument (before send if present). This combines naturally with lifetimes and send:
type LogEffs<'a> = Effects![GetConfig, Log<'a>];
#[effectful('a, Counter, ...LogEffs<'a>, send)]
fn complex<'a>(msg: &'a str) -> u64 {
yield_!(Log(msg));
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:
use corophage::prelude::*;
#[effect(())]
struct Log(String);
#[effect(u64)]
struct Counter;
type Effs = Effects![Log, Counter];
let program = Program::new(|mut 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.
A Yielder supports one effect operation at a time. Its yield_ and invoke methods require &mut self, so the borrow checker rejects overlapping operations such as passing two yield_ futures to join!. Await each operation before starting another.
Computation bodies may also await ordinary futures. This works inside #[effectful] functions, manual Program::new closures, and invoked sub-programs when executed with .run().await or a low-level asynk runner:
#[effectful(Counter)]
fn delayed_count() -> u64 {
tokio::task::yield_now().await;
yield_!(Counter)
}
let result = delayed_count()
.handle(async |_: Counter| Control::resume(42))
.run()
.await;
Synchronous runners handle effect suspension but cannot wait for unrelated futures. If an ordinary future returns Pending, use .run().await instead.
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.
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():
#[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, returnsResult<R, Cancelled>; the computation body must not suspend on non-effect futures.run().await— execute asynchronously, including ordinary futures awaited by the computation body.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:
fn add_logging<Effs>(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.
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:
let result = Program::new(|mut 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:
#[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:
let program = Program::new_send(|mut y: Yielder<'_, Effs>| async move {
y.yield_(Counter).await
});
tokio::spawn(async move {
let result = program
.handle(async |_: Counter| Control::resume(42u64))
.run()
.await;
});
The result and every async handler future must be Send. Handlers that capture state across an await should clone it into a non-lending future, for example move |effect| { let state = Arc::clone(&state); async move { ... } }. Local programs continue to accept lending async |effect| { ... } handlers.