Algebraic effects in C++ but it's just coroutines I guess
Coroutines are effects, actually. Or wait - is that backwards?
I have a weakness for plain structs and functions that do what they say. I have less affection for building an object hierarchy every time some code wants to ask a question, write a log message, or fail politely.
But those things still have to happen. The useful question is whether the code that asks for them has to be welded to the code that does them.
Algebraic effects split those two jobs. A computation can say “I need a name” without deciding whether the answer comes from a terminal, a test fixture, or a tiny, deeply judgmental robot. A handler supplies that interpretation later.
I made cpp-fx to see how far that idea could go with C++20 coroutines. It is a single header, the call sites are direct-style C++, and the effect list lives in the return type. The computation gets to ask for things, but the compiler insists on seeing its permission slip.
Why bother?
Take a function that has to do five fallible things: read configuration, parse a request, resolve a user, fetch a record, and decode that record. None of this is exotic, which is exactly why the shape matters.
One broad approach is errors as values: a function returns either its ordinary result or a description of what went wrong. C++ spells that with std::expected. The compiler makes the failure case difficult to forget, and each step can transform its error before returning it:
using Result = std::expected<Profile, Error>;
auto load_profile(UserId id) -> Result {
auto config = read_config();
if (!config)
return std::unexpected(config.error());
auto request = parse_request(*config, id);
if (!request)
return std::unexpected(request.error());
auto user = resolve_user(*request);
if (!user)
return std::unexpected(user.error());
auto record = fetch_record(*user);
if (!record)
return std::unexpected(record.error());
auto profile = decode_profile(*record);
if (!profile)
return std::unexpected(profile.error());
return *profile;
}
The same idea appears outside C++. Rust uses Option<T> for “a value or nothing” and Result<T, E> for “a value or an error.” ok_or turns absence into a specific error, while ? returns an Err to the caller:
fn load_profile(id: UserId) -> Result<Profile, Error> {
let config = read_config()?;
let request = parse_request(&config, id)?;
let user = find_user(&request).ok_or(Error::MissingUser)?;
let record = fetch_record(&user)?;
decode_profile(&record)
}
Go makes the pair explicit and checks it after each call:
config, err := readConfig()
if err != nil {
return Profile{}, err
}
request, err := parseRequest(config, id)
if err != nil {
return Profile{}, err
}
// The same check follows each remaining fallible call.
The syntax differs, but recovery always needs a decision point. Some caller receives the failure and chooses whether to propagate, retry, translate, report, or replace it with a fallback. Returning early only postpones that choice to a caller with more context.
The C++ version is honest, and mostly propagation. An and_then chain can compress it into a pipeline of lambdas. That works well when the error value is the point of the composition. I find it less readable when I want the successful path to look like a sequence of ordinary steps.
Exceptions preserve that sequence:
auto load_profile(UserId id) -> Profile {
auto config = read_config_or_throw();
auto request = parse_request_or_throw(config, id);
auto user = resolve_user_or_throw(request);
auto record = fetch_record_or_throw(user);
return decode_profile_or_throw(record);
}
try {
show(load_profile(id));
} catch (const ProfileError& error) {
report(error);
}
The catch block is still the recovery decision point; the exception reaches it by unwinding instead of appearing in the return value. The happy path is immaculate. The price is that Profile says nothing about ProfileError, the compiler does not require the catch, and any function in the call tree can introduce another exception without changing its signature. Exceptions compose operationally, but their contract does not compose in the type system.
An effect handler is another place to make that decision, but it receives a typed request together with the suspended rest of the computation. Failure can be one such request, though effects also cover dependencies, logging, state, and other questions that are not errors. The effectful version keeps the straight line while naming every operation it may request:
struct ReadConfig : Effect<Config> {};
struct ParseRequest : Effect<Request> { Config config; UserId id; };
struct ResolveUser : Effect<User> { Request request; };
struct FetchRecord : Effect<Record> { User user; };
struct DecodeProfile : Effect<Profile> { Record record; };
using ProfileIO =
Row<ReadConfig, ParseRequest, ResolveUser, FetchRecord, DecodeProfile>;
auto load_profile(UserId id) -> ProfileIO::Fx<Profile> {
auto config = perform(ReadConfig{});
auto request = perform(ParseRequest{.config = config, .id = id});
auto user = perform(ResolveUser{.request = request});
auto record = perform(FetchRecord{.user = user});
co_return perform(DecodeProfile{.record = record});
}
auto profile = load_profile(id).run(ProductionProfileIO{});
co_return is the coroutine spelling of return, and perform issues a request. A Row is a list of allowed requests carried by the type checker, not a runtime container. ProfileIO::Fx<Profile> reads as “a computation that eventually returns Profile and may perform any operation in ProfileIO.” Nothing runs merely because that value was created; .run(...) supplies the interpretations and drives the suspended computation.
The five fallible operations have moved behind a checked capability boundary. ProductionProfileIO can turn a database miss into an abort, a retry, a fallback, or a domain error. A test handler can answer all five requests from memory. The coroutine reads like the exception version, but ProfileIO::Fx<Profile> is an explicit contract and .run() is rejected unless its handlers cover that contract.
There are costs. An effect row is more machinery than one std::expected, the coroutine has a frame, and a poorly chosen effect can hide work just as effectively as a poorly chosen service locator. The point is not that effects win every error-handling contest. The point is that they combine the happy-path focus of exceptions with much of the validation and caller control of std::expected.
The telos of the system is composability. A small computation declares a small row. co_await composes computations and propagates the union of their effects. .bind() can interpret one effect locally and remove it from the outward contract. The final boundary supplies the policies that remain. Code can be assembled first and interpreted later without turning every intermediate return type into a hand-written plumbing diagram.
The shape of cpp-fx
A request and its handlers
At the cpp-fx surface, an effect begins as a type describing a request and the kind of answer it expects:
#include "effects.hpp"
#include <iostream>
#include <string>
#include <variant>
using namespace fx;
struct Ask : Effect<std::string> {
std::string prompt;
};
Ask carries a prompt to whoever handles it. Effect<std::string> says the computation resumes with a std::string. No base-class interface stuffed into a Person, no virtual-dispatch table pointer hiding in your application data. The type is a message.
Now a coroutine can perform that request in the middle of ordinary-looking code:
auto greet() -> Row<Ask>::Fx<std::string> {
auto name = perform(Ask{.prompt = "Name: "});
co_return "Hello, " + name + "!";
}
Here is one interpretation:
struct TerminalAsk : Handler<Ask> {
void handle(Ask e, auto resume) {
std::cout << e.prompt;
std::string answer;
std::getline(std::cin, answer);
resume(answer);
}
};
int main() {
std::cout << greet().run(TerminalAsk{});
}
And here is another:
struct TestAsk : Handler<Ask> {
void handle(Ask, auto resume) {
resume("Ada");
}
};
auto result = greet().run(TestAsk{}); // "Hello, Ada!"
Same coroutine. Different answer policy. The test version does not need a mock object graph or a terminal with a convincing fake moustache.
The handler receives a lightweight Resume<E> token, not a std::function. Calling it supplies the result and resumes the coroutine once.
The row is part of the type
Row<Ask>::Fx<std::string> says two things: this computation eventually produces a string, and it may ask for an Ask. If it also logs, the row says so:
struct Log : Effect<std::monostate> {
std::string message;
};
using IO = Row<Ask, Log>;
auto greet_and_log() -> IO::Fx<std::string> {
perform(Log{.message = "asking for a name"});
auto name = perform(Ask{.prompt = "Name: "});
perform(Log{.message = "got a name"});
co_return "Hello, " + name + "!";
}
std::monostate is a unit-like C++ value used when an effect has no interesting reply, much like Rust’s () or void in languages that allow it as a result type.
The list is not decorative documentation. Perform an undeclared effect and the code is rejected. Run the computation without every required handler and that is rejected too. The squiggle belongs at the mistaken perform() or .run() call, not in a 600-line template-instantiation autopsy. The cpp-fx validation notes explain how the deleted overloads and concepts make that happen.
The types also propagate through co_await. If greet() awaits another coroutine that can ask a question, greet() must declare that effect as well. One handler at the outer .run() can serve both. Rows can be nested and flatten at compile time, so a codebase can use names such as IO instead of repeating Row<Ask, Log> at every turn. See effects and rows and propagation.
Yes, it is paperwork. It is also paperwork the compiler can check before your program gets to production and starts improvising.
The surface model has three pieces: a request says what is needed, a row says what may be asked, and a handler says what the request means here. The remaining question is where the suspended “rest of the computation” comes from.
What an effect actually is
Operationally, an effect is a resumable computation asking a question.
Consider the point where greet() performs Ask:
auto greet() -> Row<Ask>::Fx<std::string> {
auto name = perform(Ask{.prompt = "Name: "});
co_return "Hello, " + name + "!";
}
At perform, the computation splits into two useful pieces:
request:
Ask{.prompt = "Name: "}
rest of the computation:
take the supplied string as name
return "Hello, " + name + "!"
The second piece is a delimited continuation. It is “the rest of this function,” but only up to the handler that encloses it. The effect handler receives the request and that continuation. It answers the question by resuming the continuation with a value:
void handle(Ask e, auto resume) {
std::cout << e.prompt;
resume(read_line());
}
An ordinary callee decides how to produce its return value. An effect operation only states what it wants. The handler may answer now or later, substitute a test value, log, retry an underlying operation, transform the final result, or abort without resuming. Code after perform stays suspended until the handler decides how, or whether, to proceed.
Exceptions also transfer control outward, but they abandon the intervening frames. An effect carries a representation of those frames with it, allowing the handler to supply an answer and continue from the suspension point. “Resumable exception” is incomplete, but useful as a first approximation.
Koka is the clearest “effect language” comparison. Its function types contain inferred effect rows, and its handlers can interpret named operations while resuming the rest of the computation. OCaml 5 exposes similar operation-and-continuation machinery, but does not statically guarantee that every performed effect has a handler.
cpp-fx borrows pieces rather than pretending C++ has secretly become Koka. Its Row<...> makes capabilities visible and checked in the return type; its continuation is one-shot, matching what an ordinary C++ coroutine frame can naturally represent.
The same pressure in production TypeScript
The TypeScript framework Effect is a strong production-oriented expression of the same design pressure. Its vocabulary is workflows, services, errors, retries, resources, and concurrency. You do not have to begin with delimited continuations.
An Effect value has the type Effect<Success, Error, Requirements>. It is a lazy description of a computation, not the computation already running. The third parameter records the services that must be provided, while the second records expected failures:
import { Context, Effect } from "effect"
class Ask extends Context.Service<
Ask,
{
readonly read: (prompt: string) => Effect.Effect<string>
}
>()("Ask") {}
type EmptyName = { readonly _tag: "EmptyName" }
const EmptyName: EmptyName = { _tag: "EmptyName" }
const greet = Effect.gen(function* () {
const ask = yield* Ask
const name = yield* ask.read("Name: ")
if (name.length === 0) {
return yield* Effect.fail(EmptyName)
}
return `Hello, ${name}!`
})
// Effect.Effect<string, EmptyName, Ask>
Effect.gen uses JavaScript generators to recover direct-style control flow: yield* composes the next description and gives its successful value back to the function. Ask remains in the inferred requirements type, just as Ask remains in a cpp-fx row, and EmptyName remains in the error channel until something handles it.
Providing a service is the practical equivalent of choosing an interpretation. It removes that requirement from the resulting type; handling the tagged error removes the expected failure:
const TestAsk = Ask.of({
read: () => Effect.succeed("Elijah")
})
const testGreeting = greet.pipe(
Effect.provideService(Ask, TestAsk),
Effect.catchTag(
"EmptyName",
() => Effect.succeed("Hello, stranger!")
)
)
// Effect.Effect<string, never, never>
const result = Effect.runSync(testGreeting)
The result looks a lot like a checked effect row interpreted at the boundary. Typed requirements, typed failures, replaceable interpretations, and readable happy paths all survive in ordinary production TypeScript.
That resemblance is about the API. It does not explain the mechanism behind cpp-fx. Effect composes an immutable program description for its runtime; cpp-fx suspends a C++ coroutine and gives a handler the one-shot continuation. To get back to why coroutines fit algebraic effects so tightly, we need to ask what one-shot commits that continuation to doing.
One shot, many shots
“One-shot” answers a very literal question: how many times may the handler resume the captured continuation?
A one-shot handler chooses one answer and the computation proceeds once:
struct Choose : Effect<bool> {};
auto label() -> Row<Choose>::Fx<std::string> {
auto formal = perform(Choose{});
co_return formal ? "Dr. Skeirik" : "Elijah";
}
struct Informal : Handler<Choose> {
void handle(Choose, auto resume) {
resume(false); // run the rest of label() once
}
};
A multi-shot continuation may be resumed more than once. A nondeterminism handler could conceptually explore both branches:
// Conceptual multi-shot handler, not valid cpp-fx:
auto handle(Choose, continuation k) {
return collect(
k.resume_copy(true),
k.resume_copy(false)
);
}
The first resumption runs the rest of label() with formal == true. The second must run that same captured rest of the function again with formal == false. Backtracking, search, and probabilistic exploration use this kind of branching. The implementation must preserve or clone continuation state, including locals and stack frames, instead of consuming it as execution advances.
OCaml 5 is explicitly one-shot: attempting to continue the same k twice raises Continuation_already_resumed. The manual calls out two reasons. One-shot continuations avoid copying stack frames, and they interact more sanely with linear resources such as sockets and file descriptors. Other algebraic-effect systems can express multi-shot handlers, while particular implementations may optimize one-shot cases or expose linear variants.
cpp-fx is one-shot by construction and by contract. Resume<E> stores the handle to one suspended C++20 coroutine frame. Calling it puts the reply into that frame and resumes it forward from its current suspension point. Once that frame has advanced, there is no untouched copy to restart. Supporting multi-shot behavior would require a different representation, such as cloning continuation state, replaying the computation, or compiling it into a persistent structure. The library does none of those things, deliberately.
That restriction is not an awkward accident beside the implementation. It explains why coroutines are such a natural fit. A coroutine is fundamentally a resumable computation. At suspension, it hands control back while preserving “the rest of my function.” An effect operation needs exactly that object, accompanied by a question: how do I proceed?
Once stated that way, the boundary between the two starts looking rather thin.
Why coroutines fit
How C++ gives cpp-fx a continuation
If you know async/await from JavaScript, Python, C#, or Rust, the spelling will look familiar. A C++ coroutine is not automatically a thread, task, or event loop, though. It is any function containing co_await, co_yield, or co_return that the compiler lowers into a resumable state machine. The compiler stores parameters, locals that survive suspension, the current suspension point, and a promise object in a separate coroutine state, commonly called the coroutine frame.
The promise object is library-defined machinery that controls how the coroutine starts, finishes, returns a value, and reports an uncaught exception. It is unrelated to std::promise. A std::coroutine_handle is a small, non-owning handle used outside the coroutine to resume or destroy that state.
The expression co_await value asks value for an awaiter. In its simplest form, an awaiter participates through three methods:
bool await_ready(); // Is the answer already available?
void await_suspend(coroutine_handle); // Save/transfer control after suspension.
T await_resume(); // What does co_await evaluate to?
await_suspend may also return bool or another coroutine handle to control whether and where execution transfers. Lewis Baker’s operator co_await walkthrough is a useful longer explanation of this protocol.
An awaitable is simply an object that supplies that protocol, either itself or through operator co_await. It does not have to represent asynchronous I/O. It can represent a timer, a generator handoff, or, here, an effect request.
cpp-fx defines PerformAwaitable<E> for that job. The perform(e) macro expands to co_await perform_impl(e). await_ready() says the request is not already answered, await_suspend() records the suspended coroutine and exposes the request to a handler, and await_resume() returns the handler’s reply as the value of the original perform(e) expression.
The compiler therefore turns greet() into the state machine; cpp-fx decides what one suspension point means. perform() suspends it, the request travels to a handler, the handler supplies the answer, and the coroutine carries on. The direct-style surface and the continuation-based model are the same program viewed from opposite sides of co_await.
Coroutines and effects keep turning into each other
The title of this article is not only a joke. Satoru Kawahara and Yukiyoshi Kameyama’s paper “One-shot Algebraic Effects as Coroutines” gives a direct translation of one-shot algebraic effects and handlers into ordinary asymmetric coroutines.
The paper’s observation is almost suspiciously small:
perform(effect, argument)
≈ yield(EffectRequest{effect, argument})
continuation(reply)
≈ resume(coroutine, reply)
An effect operation transfers control to its nearest handler. A coroutine yield transfers control to the code that resumed it. If a performed operation is encoded as a tagged yielded value, then the suspended coroutine already is the rest of the computation the handler needs. Resuming the coroutine with a reply invokes that one-shot continuation.
cpp-fx uses that exact seam. In the actual header, perform(e) expands to a co_await of a PerformAwaitable<E>. Its await_suspend stores the coroutine handle and exposes the effect payload. Resume<E> writes the handler’s reply into that awaitable and resumes the saved handle:
template <Effectful E>
struct PerformAwaitable {
bool await_ready() const noexcept { return false; }
template <typename Promise>
void await_suspend(std::coroutine_handle<Promise> caller) noexcept {
caller_ = caller;
caller.promise().effect_tag = &detail::effect_tag_v<E>;
caller.promise().payload_ptr = this;
}
typename E::result_type await_resume() {
return std::move(result_);
}
};
template <Effectful E>
void Resume<E>::operator()(typename E::result_type reply) const {
pa->result_ = std::move(reply);
pa->caller_.resume();
}
The sample is abridged, but not metaphorical. A suspended C++ coroutine handle represents the effect continuation. The handler never reconstructs “everything after perform” as a lambda; the compiler has already lowered that remainder into the coroutine frame.
The relationship also runs backwards. The paper implements coroutine yield as an algebraic effect: performing Yield(value) hands the value and continuation to a handler; the handler stores that continuation as the coroutine’s new resumable state. In cpp-fx, the same idea makes a small generator:
struct Yield : Effect<std::monostate> {
int value;
};
auto count_to_three() -> Row<Yield>::Fx<void> {
perform(Yield{.value = 1});
perform(Yield{.value = 2});
perform(Yield{.value = 3});
co_return;
}
struct Collect : Handler<Yield> {
std::vector<int>& values;
void handle(Yield e, auto resume) {
values.push_back(e.value);
resume({});
}
};
std::vector<int> values;
count_to_three().run(Collect{.values = values});
From one direction, perform is a disciplined yield and the handler resumes the coroutine. From the other, yield is an effect operation and resume is its handler. “Coroutine” and “effect” are not synonyms, but for one-shot continuations they are close enough to encode each other with very little ceremony.
cpp-fx stays on the one-shot side of that translation: a normal Resume token is called once. The smaller contract still covers logging, dependency requests, state, failure policies, generators, and plenty of other practical control flow.
The paper’s Ruby implementation, ruff, makes the translation unusually visible. A Ruby Fiber is its coroutine primitive. An effect literally calls Fiber.yield with a tagged request, while the handler wraps the computation in a Fiber and gives the user a continuation that calls Fiber#resume:
def perform(*args)
Fiber.yield Eff.new(@id, args)
end
def run(&computation)
coroutine = Fiber.new(&computation)
continue(coroutine).call(nil)
end
def continue(coroutine)
->(*reply) { handle(coroutine, coroutine.resume(*reply)) }
end
Ruff hides that Fiber protocol behind an effect-system API:
Double = Ruff.instance
Log = Ruff.instance
handler = Ruff.handler
.on(Double) { |k, value| k[value * 2] }
.on(Log) { |k, message| puts(message); k[] }
handler.run do
value = Double.perform(21)
Log.perform("answer = #{value}")
end
Double.perform(21) yields control. The handler receives the suspended Fiber as k, resumes it with 42, and the expression evaluates to that reply. Ruff and cpp-fx make different choices about typing and implementation details, but both exploit the same tight knot: suspension captures the continuation; handling decides what value resumes it.
Interpreting effects at the boundary
The coroutine translation explains how a request can suspend and resume. The library becomes useful when the same request can receive different meanings at different boundaries: failure policy, production dependency, deterministic test, or instrumentation.
Failure as policy
Here is a less sociable effect:
struct Fail : Effect<int> {
std::string reason;
};
auto divide(int a, int b) -> Row<Fail>::Fx<int> {
if (b == 0)
co_return perform(Fail{.reason = "division by zero"});
co_return a / b;
}
struct Fallback : Handler<Fail> {
int value;
void handle(Fail, auto resume) { resume(value); }
};
divide(10, 0).run(Fallback{.value = 0}) returns zero. Swap the handler and the same computation can log, return a sentinel, or apply a different policy. Like an exception, Fail propagates without a manual check at every call; unlike an exception, it remains named in the row and .run() must account for it.
You can also handle effects partway down a call chain with .bind(). A helper might choose its own fallback for Fail while leaving Log visible to its caller. The policy can sit where it makes sense instead of dragging every implementation detail to main() like a family secret.
Retries still have to live where work can actually be repeated. A one-shot Resume token cannot rewind a computation after Fail; a handler may retry an underlying “fetch this record” operation before resuming, or the computation can model retry as a separate decision.
Handlers do not require domain objects to inherit from service interfaces. The effect stays a separate request type, while terminal, test, recording, or composite handlers live at the boundary. No object hierarchy or global service locator is required.
Dependency injection, with a contract
If “the caller chooses an implementation” sounds familiar, good: handlers have a lot in common with dependency injection. A computation declares what it may need in its effect row; the boundary supplies handlers that decide how those needs are met. Production can pass a terminal Ask, while a test passes a scripted one. The difference from the usual constructor-injected service is that the dependency is an operation the computation can perform, and the effect row makes that requirement visible in its return type.
Ordinary constructor injection might express a user lookup this way. std::optional<User> is C++’s “maybe a user” type, serving the same role as Rust’s Option<User>:
struct UserStore {
virtual ~UserStore() = default;
virtual std::optional<User> find(UserId) = 0;
};
struct Greeter {
UserStore& users;
auto greet(UserId id) -> std::string {
auto user = users.find(id);
return user ? "Hello, " + user->name : "Hello, stranger";
}
};
SqlUserStore production{db};
FakeUserStore test{{User{.id = 7, .name = "Ada"}}};
Greeter{production}.greet(7);
Greeter{test}.greet(7);
This works. It gets awkward when a function needs one operation from each of six service objects, or when four intermediate constructors have to forward dependencies they never use.
The effect version injects the operation rather than a service object:
struct FindUser : Effect<std::optional<User>> {
UserId id;
};
auto greet(UserId id) -> Row<FindUser>::Fx<std::string> {
auto user = perform(FindUser{.id = id});
co_return user ? "Hello, " + user->name : "Hello, stranger";
}
struct SqlUsers : Handler<FindUser> {
Database& db;
void handle(FindUser e, auto resume) {
resume(db.find_user(e.id));
}
};
struct FakeUsers : Handler<FindUser> {
std::optional<User> user;
void handle(FindUser, auto resume) {
resume(user);
}
};
greet(7).run(SqlUsers{.db = db});
greet(7).run(FakeUsers{.user = User{.id = 7, .name = "Ada"}});
Both versions defer implementation choice to the boundary. The effect version does not require greet to own, store, or forward a service reference. Its dependency appears as Row<FindUser> and composes with the rows of functions it awaits. That is dependency injection, but the injected contract is a typed operation plus a continuation.
First-class modules, as another analogy
An OCaml first-class module offers another way to see the same separation. A module signature names available operations, a packed module supplies their implementation, and the caller chooses which package to pass:
module type CLOCK = sig
val now : unit -> float
end
let timestamp (module Clock : CLOCK) message =
Printf.sprintf "[%.3f] %s" (Clock.now ()) message
module System_clock : CLOCK = struct
let now () = Unix.gettimeofday ()
end
let line = timestamp (module System_clock) "ready"
The signature says what is available; the packed module supplies an implementation. The corresponding effect shape is:
struct Now : Effect<std::chrono::system_clock::time_point> {};
auto timestamp(std::string message) -> Row<Now>::Fx<std::string> {
auto time = perform(Now{});
co_return format_time(time) + " " + message;
}
struct SystemClock : Handler<Now> {
void handle(Now, auto resume) {
resume(std::chrono::system_clock::now());
}
};
struct FixedClock : Handler<Now> {
std::chrono::system_clock::time_point value;
void handle(Now, auto resume) {
resume(value);
}
};
auto live = timestamp("ready").run(SystemClock{});
auto test = timestamp("ready").run(FixedClock{.value = epoch});
Now resembles the one-operation signature, the handlers resemble module implementations, and .run() is the boundary where one is selected. The analogy stops there: a module function simply returns, while an effect handler receives the suspended computation and may resume, transform, or abort it.
Both comparisons point at the same design choice: keep the capability a computation asks for separate from the policy that provides it. That gives you swappable implementations without making every domain value inherit from a service interface or carrying a vtable around.
For more on named and composite handlers, resume tokens, and result-transforming handlers, there is a handlers guide. For higher-order functions that preserve or change effect rows, there is the rather more template-shaped row-polymorphism guide. I promise that last one is useful; I only promise the templates will be polite about it.
Costs you can inspect
Coroutines are not free. Constructing an Fx coroutine creates the coroutine frame described above. The default setup uses a small thread-local free-list slab for frames that fit: each thread keeps a bounded cache of previously freed, same-sized frame slots and reuses them before asking the general allocator. The important distinction is that each perform() does not allocate: its bookkeeping lives in the coroutine frame. A frame is allocated once for the computation, not once per request.
The project’s performance notes report roughly 19 ns for a single perform() and about 5.8 ns per perform() when amortized over 10,000 operations on the documented x86-64/GCC 13 setup. Those are measurements, not a universal performance warranty issued by the International Committee for Making Numbers Look Nice. The suspend/resume round trip is real overhead; tight loops should measure their own workload.
There are a few ways to keep frame allocation on a short leash:
ScopedArenauses an inline buffer for a bounded scope.ScopedFreeListprovides a reusable fixed-size pool.StackFx/make_stack_fxput a coroutine frame in storage owned by a stack-local object.FX_NO_TLSremoves the library’s reliance on thread-local storage; you then provide an allocator explicitly.
That last point is useful for embedded work, but it is not pixie dust. The compiler and standard library still need to support C++ coroutines, frame sizes depend on the coroutine and compiler, and nested live coroutines need enough pool slots. frame_size_v helps size a pool; pairing a stack strategy with no_heap can make accidental fallback to the heap fail loudly in tests.
This is the trade I wanted to explore: direct-style code, checked effect declarations, and an explicit allocator story. Not “zero cost” as a mood. Actual places to inspect, bound, and measure. The stack-allocation test shows the intended usage.
Choosing the tool
cpp-fx is a single-header experiment in making effectful C++ code easier to compose. It asks for C++23 and a recent compiler (GCC 13+ or Clang 17+), and its coroutine frames have a cost. A row does not prove that a function is pure, and a handler is not a license to hide global side effects wherever convenient.
There are other good tools for particular jobs. std::expected, Rust’s Result, or an ordinary Go error return is often cleaner when a function has one success-or-error result. Exceptions may be right when failure is exceptional. A callback or plain function parameter may be enough for one dependency. Effects get interesting when several capabilities should remain explicit in the type while their interpretations stay replaceable.
Useful rabbit holes
If one of the layers above still feels like a magic trick, these are good places to pull it apart:
- C++ coroutines on cppreference is the language-level reference for frames, promise objects, handles, suspension, and the three coroutine keywords.
- Coroutine Theory by Lewis Baker builds the mental model of suspendable operations before getting buried in C++ syntax.
- Understanding
operator co_awaitexplains the awaitable/awaiter protocol and why a library gets to define what suspension means. - Koka’s effect-types chapter shows effect rows and handlers as language features rather than a library encoding.
- The OCaml 5 effect-handler chapter develops effects from a small exchange operation through schedulers, generators, one-shot linearity, and runtime fibers.
- Effect’s service guide shows the production TypeScript version of checked requirements and replaceable interpretations.
- One-shot Algebraic Effects as Coroutines is the formal bridge used in this article, and
ruffis the corresponding Ruby implementation. - The
cpp-fxAPI reference is the shortest route from the concepts here to the exact library types and constraints.
If the idea is new, the phrase “algebraic effects” can sound like a committee named a feature after losing a bet. The core move is friendlier than the label: keep a computation’s requests separate from the code that answers them. The fun part is seeing how much of that idea fits into regular C++ before the template error messages begin composing their own opera.
The repository has the header, tests, docs, and benchmarks. Read the quick start, then try replacing one real dependency with an effect and two small handlers. If it makes the code clearer, excellent. If it makes your compiler develop a personality, at least the handler is swappable.