Continuation Passing
Pass a callable representing "what happens next" into a function instead of returning a value, enabling composable async pipelines and stack-safe recursion.
Continuation Passingsince C++11A transformation in which a function, instead of returning its result, accepts an additional callableβthe continuationβand passes the computed value to it, making the "rest of the program" an explicit, composable first-class argument.
Overview
In direct style, a function computes a value and hands it back via return. In continuation-passing style (CPS), the function receives an extra callable representing everything that should happen with that value:
// Direct style
int add(int a, int b) { return a + b; }
// Continuation-passing style
template <typename K>
void add_cps(int a, int b, K k) { k(a + b); }The continuation k is the program's futureβthe computation that would have followed the return at the call site. CPS is not simply "callbacks with a different name": it is a systematic control-flow transformation that externalises sequencing.
C++98 allowed rudimentary CPS via function pointers and hand-written functors. C++11 made it idiomatic. Lambdas eliminated boilerplate, std::function provided type-erased storage, and perfect forwarding let continuations move through call chains without copies.
When CPS is the right tool:
- Async pipelines β operations that complete at unpredictable times; CPS expresses sequencing without blocking the thread.
- Trampolining β converting deep recursion into a flat loop by making each step return the next step as a thunk, eliminating stack growth.
- Parser combinators β chaining partial parses through continuations avoids building intermediate data structures.
- Explicit error paths β splitting into success and error continuations makes branching impossible to ignore at the call site.
C++20 coroutines (co_await) solve many of the same problems with cleaner syntax. Prefer coroutines when they are available and the overhead is acceptable. CPS remains valuable when targeting C++17 environments, when the zero-cost template form is required, or when the callback granularity is below what coroutine frame allocation permits.
Syntax
Template continuation β zero overhead
Pass the continuation as an unconstrained or concept-constrained template parameter. The compiler inlines the call and the abstraction vanishes:
#include <utility>
// C++11: perfect-forward the continuation to respect move-only callables
template <typename T, typename K>
void identity_cps(T value, K&& k) {
std::forward<K>(k)(std::move(value)); // C++11 move + forward
}With C++20 concepts you can constrain the continuation without paying a runtime cost:
#include <concepts>
#include <utility>
// C++20
template <typename T, std::invocable<T> K>
void identity_cps(T value, K&& k) {
std::forward<K>(k)(std::move(value));
}Type-erased continuation β std::function
Use std::function (C++11) when the continuation must be stored in a data structure, passed across a virtual boundary, or exported through a public API compiled separately:
#include <functional>
// C++11
using IntCont = std::function<void(int)>;
void fetch_async(int id, IntCont on_result);std::function requires the callable to be CopyConstructible and performs a heap allocation for large callables. Since C++23, std::move_only_function removes the copy requirement, enabling continuations that capture unique_ptr or other move-only resources:
#include <functional>
// C++23
using Task = std::move_only_function<void(int)>;Examples
Chaining continuations
Continuations compose by nesting: the outer continuation wraps the inner one, threading the result forward:
#include <iostream>
#include <string>
template <typename K>
void parse_int(const std::string& s, K k) {
k(std::stoi(s)); // std::stoi: C++11
}
template <typename K>
void double_it(int n, K k) {
k(n * 2);
}
int main() {
// parse β double β print
parse_int("21", [](int n) {
double_it(n, [](int result) {
std::cout << result << '\n'; // 42
});
});
}Beyond two or three levels, inline nesting degrades readability. Name intermediate lambdas or introduce a combinator that sequences two CPS functions.
Dual continuations for error handling
Splitting the continuation into a success path and an error path makes failures structurally impossible to ignore:
#include <filesystem>
#include <fstream>
#include <string>
namespace fs = std::filesystem; // C++17
template <typename OnOk, typename OnErr>
void read_file(const fs::path& path, OnOk on_ok, OnErr on_err) {
std::ifstream f(path);
if (!f) {
on_err("cannot open: " + path.string());
return;
}
std::string contents(
(std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
on_ok(std::move(contents)); // C++11 move
}
// Call site β both paths must be handled; neither is optional
read_file(
"/etc/hostname",
[](std::string data) { /* process */ },
[](std::string err) { /* log/abort */ }
);This mirrors std::expected<T, E> (C++23) but composes naturally in pre-C++23 codebases and carries richer branching logic when more than two outcomes are possible.
Trampolining β stack-safe recursion
A naΓ―ve CPS function still consumes one stack frame per recursive step because each continuation call is a function call. A trampoline iterates instead: each step returns the next step as a zero-argument thunk, and a flat loop drives execution:
#include <functional>
#include <variant> // C++17
template <typename T>
struct Thunk {
using Step = std::function<Thunk<T>()>; // C++11
std::variant<T, Step> state; // C++17
static Thunk done(T v) { return {std::move(v)}; }
static Thunk step(Step s) { return {std::move(s)}; }
};
template <typename T>
T trampoline(Thunk<T> t) {
while (std::holds_alternative<typename Thunk<T>::Step>(t.state)) {
t = std::get<typename Thunk<T>::Step>(t.state)();
}
return std::get<T>(std::move(t.state));
}
Thunk<long long> factorial_step(long long n, long long acc) {
if (n <= 1) return Thunk<long long>::done(acc);
return Thunk<long long>::step([n, acc] {
return factorial_step(n - 1, n * acc);
});
}
int main() {
// Runs without growing the stack regardless of n
long long r = trampoline(factorial_step(100'000LL, 1)); // C++14 digit separator
}Each "recursive" step is a heap-allocated lambda invoked from the flat loop. Stack depth stays constant; only heap allocation grows with the depth of the computation.
Best Practices
Prefer templates over std::function in library code. A templated continuation is typically inlined entirely. Reserve std::function for ABI boundaries, virtual dispatch, or storage in heterogeneous containers. Profile before introducing std::function in hot paths.
Take continuations by K&& and std::forward them. Continuations often capture move-only resources. Accepting K&& and forwarding on use respects C++11 move semantics and avoids spurious copies of captured state.
Enforce single invocation. CPS is implicitly a linear ownership contract: each continuation must be called exactly once. Double-invocation and non-invocation are both logic errors. In debug builds, wrap the continuation in a small guard that asserts it has been called exactly once on destruction.
Name intermediate steps when chaining more than two levels. Nested lambdas rapidly produce code that is harder to read than the direct-style equivalent. Assign each step a named local and pass it explicitly; the resulting style resembles a sequential program.
Migrate to C++20 coroutines when complexity warrants it. A four-step CPS chain written with co_await in a coroutine is dramatically clearer. CPS and coroutines are dualsβa coroutine is a CPS function with language-level sugar. The migration threshold is typically when the continuation-passing structure starts being the dominant source of reviewer questions.
Common Pitfalls
Dangling captures. Lambdas capturing local variables by reference ([&]) are unsafe when the continuation outlives its enclosing scopeβa common scenario in async code. Capture by value ([=]) or use shared_ptr (C++11) for shared ownership across asynchronous lifetimes.
Forgotten invocations. A continuation that is never called produces a silent bugβdownstream computation simply stalls. In async systems this manifests as leaked resources or promises that never resolve. Static analysis tools and debug-mode wrappers that assert on destruction are the most reliable safeguard.
std::function allocation in tight loops. Constructing a std::function from a large lambda allocates on every construction. If a CPS loop creates a new continuation at each iteration, that allocation may dominate runtime. Switch to a template or a stack-local small-function implementation when profiling confirms the problem.
Accidental copies of large continuations. Passing std::function by value when the callable captures substantial state copies that state on every call. Accept by const K& when the continuation is called multiple times (fan-out); by K&& when it is consumed once.
CPS Transformation in Depth
The mechanical transformation
Every direct-style function of type T f(A a) has a CPS counterpart void f_cps(A a, K k) where K is callable with T. The transformation is mechanical: replace return expr with k(expr) and add k as a final parameter. For a chain of calls the transformed versions must be nested, because each step's result feeds the next step's continuation argument:
// Direct style: three sequential steps
int step1(int x) { return x + 1; }
double step2(int n) { return n * 2.5; }
std::string step3(double d) { return std::to_string(d); }
std::string pipeline(int x) {
return step3(step2(step1(x)));
}
// CPS-transformed: each step takes a continuation
template <typename K> void step1_cps(int x, K k) { k(x + 1); }
template <typename K> void step2_cps(int n, K k) { k(n * 2.5); }
template <typename K> void step3_cps(double d, K k) { k(std::to_string(d)); }
// The pipeline: nesting mirrors the original call order, reversed
template <typename K>
void pipeline_cps(int x, K k) {
step1_cps(x, [k = std::move(k)](int n) mutable {
step2_cps(n, [k = std::move(k)](double d) mutable {
step3_cps(d, std::move(k));
});
});
}The nesting depth equals the number of steps. With three levels it is still readable. With six or more it becomes what practitioners call callback hell: each level adds another indent, error paths must be repeated at every level, and the logical flow is obscured by the structural scaffolding.
Callback hell and how to escape it
Callback hell is not a problem with CPS itself β it is a problem with inline anonymous nesting of continuations. The escape routes are:
- Named lambdas. Assign each continuation to a named local variable and pass it by move. The nesting vanishes; the sequential structure is restored to the eye, though the execution order is still CPS.
then/and_thencombinators. Write a smallthenadapter that takes two CPS functions and wires the output of the first to the input of the second. Chaining withthenreads left-to-right like direct style.- C++20 coroutines. The most ergonomic escape:
co_awaitis literally a continuation insertion point managed by the compiler.
// Named-lambda escape from three-level nesting
template <typename Final>
void pipeline_named(int x, Final sink) {
auto after_step3 = std::move(sink);
auto after_step2 = [after_step3 = std::move(after_step3)](double d) mutable {
step3_cps(d, std::move(after_step3));
};
auto after_step1 = [after_step2 = std::move(after_step2)](int n) mutable {
step2_cps(n, std::move(after_step2));
};
step1_cps(x, std::move(after_step1));
}Reading bottom-to-top, each named lambda says "after step N, do step N+1". The structure is more explicit and no harder to review than the direct-style original.
Template CPS vs. std::function β the Cost Model
Template CPS: zero-overhead when the compiler can see the continuation
When the continuation is a concrete lambda or function object visible at the call site, the compiler monomorphises the template and inlines the entire chain. No heap allocation occurs; no indirect call occurs; the calling convention overhead of std::function's internal virtual dispatch is absent. For hot code this matters:
// Compiles to the same machine code as the direct-style pipeline
// when called with a concrete lambda β all three steps can be inlined together
pipeline_cps(42, [](std::string s) { std::cout << s << '\n'; });The trade-off is template explosion: if pipeline_cps is called from twenty call sites with twenty distinct lambda types, twenty instantiations are compiled. Binary size grows and build times increase. For deeply nested template CPS with many call sites, this can outweigh the runtime savings. Strategies for containing it:
- Use
std::functionat ABI boundaries only; keep template CPS internal. - Introduce a type-erasing wrapper at the outermost level and template CPS for the inner steps.
- With C++20 concepts, model the continuation as a concept (
std::invocable<T>) so that constraint violations are caught early and the intent is documented.
std::function overhead in practice
std::function (C++11) carries three costs that matter in CPS-heavy code:
| Cost | Source | Mitigation |
|---|---|---|
| Heap allocation | Callables larger than the small-buffer size (~16β24 bytes, implementation-defined) | Keep captures small; pass large state via shared_ptr |
| Indirect call | Internal function pointer / virtual dispatch | Unavoidable with type erasure; profile before assuming it is the bottleneck |
CopyConstructible requirement | Needed for std::function assignment | Use std::move_only_function (C++23) for move-only continuations |
std::move_only_function (C++23) drops the copy requirement while keeping type erasure. It is strictly preferable to std::function when continuations capture unique_ptr, file handles, or other move-only resources and storage (not templates) is required.
CPS, Tail Calls, and the Stack Overflow Problem
Why CPS does not buy you TCO in C++
In languages like Scheme or Haskell, CPS-transformed code is guaranteed to run in constant stack space because every recursive call is a tail call β the last thing the function does β and the language runtime converts tail calls to jumps. C++ makes no such guarantee. The standard does not require tail-call optimisation (TCO). Most compilers apply it as a quality-of-implementation optimisation under certain conditions (-O2 and the right calling convention), but it is silently dropped when the compiler judges it unsafe or impossible, and there is no way to require it in portable C++.
A CPS-transformed recursive function that relies on TCO for stack safety will overflow the stack on debug builds, on platforms where the optimisation is not applied, or when a minor change to the continuation breaks the tail-position property. This is the fundamental reason trampolining exists in C++.
Trampolining: emulating TCO with a loop
A trampoline breaks the recursive chain by making each step return a description of the next step (a thunk) rather than executing it directly. A flat loop then drives those thunks one at a time, keeping the call stack at depth one regardless of how many steps the computation takes.
The Thunk<T> / trampoline pair shown in the Syntax section above is the canonical form. Key design points:
std::variant<T, Step>discriminates between a final result and a continuation to be driven.std::holds_alternativereplaces a sentinel value or boolean flag.- Each thunk is a
std::function<Thunk<T>()>. Constructing it heap-allocates the captured state, so trampolining trades stack frames for heap allocations. This is usually the right trade: heap allocation is bounded by the number of live continuations, not the call depth. - The loop runs entirely in the
trampolinefunction body. The original recursive structure exists only in the thunks; the actual instruction stream is iterative.
Coroutine alternative. If C++20 is available, a recursive computation can be rewritten as a coroutine and co_await-ed, letting the coroutine frame on the heap serve the same role as a trampoline thunk. The coroutine approach typically allocates one frame per recursive "level" and avoids the std::function cost, but requires the coroutine infrastructure (promise type, awaitable protocol). For short computations with a known depth, a simple loop or the explicit trampoline is lighter.
CPS and C++20 Coroutines
Coroutines are compiler-generated CPS
A co_await expr expression is precisely a CPS transformation applied by the compiler. The compiler splits the coroutine body at every co_await point; each suffix becomes a closure stored in the coroutine frame, which is the compiler-managed equivalent of the continuation argument k. Resuming the coroutine calls that closure.
This equivalence is not merely philosophical. Before C++20, async libraries (Boost.ASIO, libuv wrappers) exposed callback-based APIs and users wrote CPS by hand. C++20 coroutines expose the same async completion signal β the callback that fires when the I/O is done β but let the compiler generate the CPS structure, producing code that reads like direct-style:
// C++20 coroutine over an async read β reads like blocking code
asio::awaitable<std::string> read_file_async(std::string path) {
asio::stream_file f(co_await asio::this_coro::executor);
f.open(path, asio::stream_file::read_only);
std::string buf;
buf.resize(co_await asio::async_read(
f, asio::dynamic_buffer(buf), asio::use_awaitable));
co_return buf;
}The co_await asio::async_read(...) call suspends the coroutine and registers a callback with ASIO's event loop. When the read completes, ASIO calls that callback, which resumes the coroutine at the suspension point. Every manual CPS step has been replaced by a single co_await.
std::execution senders and receivers as CPS vocabulary
The C++26 sender/receiver model (formerly P2300, shipped in several major library implementations before standardisation) is CPS given a standard vocabulary:
- A sender is a thunk β it represents work that has not yet begun, analogous to the body of a CPS function before
kis provided. - A receiver is a typed continuation with three channels:
set_value(success),set_error(exception/error code),set_stopped(cancellation). It is the generalisation of the dual-continuation pattern (on_ok,on_err) shown in the Examples section. - Connecting a sender to a receiver via
std::execution::connectreturns an operation state that starts the work whenstart()is called.
Algorithms like std::execution::then, std::execution::let_value, and std::execution::when_all are CPS combinators lifted to the sender/receiver vocabulary. Understanding manual CPS is direct preparation for understanding sender/receiver: the idiom and the standard model are the same control-flow transformation at different levels of abstraction.
When to choose coroutines over manual CPS
| Situation | Prefer |
|---|---|
| C++17 or earlier | Manual template CPS or std::function CPS |
| C++20+, sequential async flow | Coroutines (co_await) |
| C++20+, fork/join, fan-out, cancellation | std::execution senders |
| Hot path, zero-overhead imperative | Template CPS with concrete lambdas |
| Public API, separate compilation | std::function / std::move_only_function |
| Deep recursion needing stack safety | Trampoline (C++17) or coroutines (C++20) |
The migration from manual CPS to coroutines is mechanical: each step_cps(arg, [](Result r) { ... }) becomes Result r = co_await step_async(arg);. The coroutine frame takes the place of the lambda closure, and the compiler handles the suspension and resumption machinery.
Practical Uses of Manual CPS in Modern C++
Even with C++20 coroutines and C++26 senders available, manual CPS retains legitimate uses:
Asynchronous I/O with C-style callback APIs
Libraries like libuv, older OpenSSL completion callbacks, and platform-native I/O APIs (Windows I/O Completion Ports via OVERLAPPED) expose C function pointers or data-pointer callbacks, not awaitable types. Wrapping them in a coroutine-compatible form requires writing the CPS layer manually at the boundary. The CPS function registers the callback; the callback fires the continuation:
// Wrapping a libuv-style callback in CPS
// uv_fs_open takes a C callback: void cb(uv_fs_t* req)
template <typename K>
void open_file_cps(uv_loop_t* loop, const char* path, K k) {
// Heap-allocate state so it survives the async call
struct State {
uv_fs_t req{};
std::decay_t<K> cont;
};
auto* s = new State{.cont = std::forward<K>(k)};
uv_fs_open(loop, &s->req, path, O_RDONLY, 0,
[](uv_fs_t* req) {
auto* state = static_cast<State*>(req->data);
state->cont(req->result); // result < 0 means error
delete state;
});
s->req.data = s;
}This pattern is the lowest common denominator for interoperating with any async C library. The State struct carries the continuation across the async gap; the lambda adapts the C callback signature to the CPS call.
Continuation-based parsing
Recursive-descent parsers written in CPS avoid building an explicit backtracking stack: instead of pushing state onto the parser's stack, each alternative is a continuation. If a parse attempt fails, the failure continuation is called with the original input position, trying the next alternative without unwinding. This technique underlies many combinator parsing libraries:
// A minimal CPS parser type
// Parser<T>: takes remaining input + success/failure continuations
template <typename T>
using Parser = std::function<void(
std::string_view remaining,
std::function<void(T, std::string_view)> on_success,
std::function<void(std::string_view)> on_fail
)>;The continuation receives both the parsed value and the unconsumed tail of the input, enabling composition without any global or instance-level backtracking state.
Compile-time CPS for type-level computation
Template metaprogramming occasionally uses CPS at the type level to chain type computations without intermediate using declarations. A type-level continuation receives a type and "returns" by inheriting from or instantiating another template:
// Type-level CPS: AddConst<T, K> calls K<const T>
template <typename T, template <typename> class K>
struct AddConst { using type = typename K<const T>::type; };
// Composing type transformations without intermediate aliases
template <typename T>
struct Identity { using type = T; };
// Result: const int*
using Result = AddConst<int, std::add_pointer_t>::type; // std::add_pointer_t is the KThis technique is rarer than runtime CPS but appears in deep metaprogramming libraries that need to thread a type through several independent transformations without naming each intermediate result.
See Also
reference/idioms/commandβ a Command object represents an operation as data; a continuation is a command specialised to carry control flow forward through a computation.reference/idioms/chain-of-responsibilityβ sequences handlers through a linked structure at runtime; CPS makes the same sequencing explicit in the static call graph, enabling compiler optimisation of the chain.