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.
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.