OnceCallback hands-on (V): chaining with then
then() threads two callbacks into a single pipeline, feeding the previous one's output into the next. It's the same old Unix pipe trick, and you've surely seen it before:
# Unix pipe: cmd1's output is cmd2's input
echo "hello" | tr 'h' 'H' | wc -cCarried over to callbacks, it's the very same story — callback A's output goes to callback B:
auto pipeline = OnceCallback<int(int, int)>([](int a, int b) {
return a + b; // step one: 3 + 4 = 7
}).then([](int sum) {
return sum * 2; // step two: 7 * 2 = 14
});
int result = std::move(pipeline).run(3, 4); // result == 14At first we figured it would be easy: then() just stitches two callbacks together, right? But OnceCallback is move-only, so the original callback's ownership has to move, wholesale, into the new callback — missing the func_, missing the token_, missing the status_, none of that will do. In this piece we'll take then() apart line by line, keeping our eyes on two things in particular: how the ownership chain gets welded together link by link, and how the void and non-void return types split into two branches.
Ownership: then()'s real problem
If you've ever used Unix pipes, the semantics of then() are pure intuition:
# Unix pipe: cmd1's output is cmd2's input
echo "hello" | tr 'h' 'H' | wc -cthen() does exactly the same thing — callback A's output is callback B's input. In code:
auto pipeline = OnceCallback<int(int, int)>([](int a, int b) {
return a + b; // step one: 3 + 4 = 7
}).then([](int sum) {
return sum * 2; // step two: 7 * 2 = 14
});
int result = std::move(pipeline).run(3, 4); // result == 14then() chains two independent callbacks into one new callback. Invoking the new callback automatically walks through the whole A → B flow.
The chained new callback has to clutch both the original callback and the continuation in its own hands. With a plain std::function this is no great feat — take a copy and be done — but OnceCallback insists on being move-only: func_, status_, token_, not one of them may be copied. All then() can do is consume *this and next, moving both households bodily into a fresh lambda closure.
Drawn out, the ownership chain is a single line:
Every link in that line is move semantics passing the baton — no copies, no sharing. This line is precisely what the whole set of move-only constraints looks like inside then().
then()'s complete implementation, line by line
Expand codeCollapse25 lines
template<typename ReturnType, typename... FuncArgs>
template<typename Next>
auto OnceCallback<ReturnType(FuncArgs...)>::then(Next&& next) && {
using NextType = std::decay_t<Next>;
if constexpr (std::is_void_v<ReturnType>) {
using NextRet = std::invoke_result_t<NextType>;
return OnceCallback<NextRet(FuncArgs...)>(
[self = std::move(*this),
cont = std::forward<Next>(next)]
(FuncArgs... args) mutable -> NextRet {
std::move(self).run(std::forward<FuncArgs>(args)...);
return std::invoke(std::move(cont));
});
} else {
using NextRet = std::invoke_result_t<NextType, ReturnType>;
return OnceCallback<NextRet(FuncArgs...)>(
[self = std::move(*this),
cont = std::forward<Next>(next)]
(FuncArgs... args) mutable -> NextRet {
auto mid = std::move(self).run(std::forward<FuncArgs>(args)...);
return std::invoke(std::move(cont), std::move(mid));
});
}
}The function signature: rvalue qualification
auto then(Next&& next) &&That trailing && makes it an rvalue-qualified member function, meaning then() only accepts std::move(cb).then(next) or a .then(next) on a temporary. Anyone who carelessly writes an lvalue call like cb.then(next) gets an on-the-spot "no matching overloaded function" from the compiler — an error that is refreshingly blunt. This is a different route from the deducing this approach run() takes — run() has to give different error messages on lvalues and rvalues, which is more trouble; then() needs no such distinction. One ref-qualifier is enough. Clean.
std::decay_t<Next>: decay strips the reference
using NextType = std::decay_t<Next>;When Next arrives it may be SomeLambda&& or SomeLambda&; with a reference clinging to it, the downstream type deductions get awkward. std::decay_t peels the reference off and leaves the bare lambda type, which std::invoke_result_t then takes — as NextType — to look up the return.
The two branches of if constexpr
What actually forks then() is whether the original callback's return type is void. Once that cut is made, the two sides look very different.
When the original callback returns a value — the non-void branch — that value must be fed onward to the continuation:
using NextRet = std::invoke_result_t<NextType, ReturnType>;std::invoke_result_t<NextType, ReturnType> asks, on our behalf at compile time: hand a value of type ReturnType to a callable of type NextType — what type does it give back? That is the new pipeline's outward return type. The work inside the lambda body is easy to narrate too: first run the original callback to obtain the intermediate result mid, then pass it along, as is, to the continuation:
auto mid = std::move(self).run(std::forward<FuncArgs>(args)...);
return std::invoke(std::move(cont), std::move(mid));The void branch wears a different face. The original callback returns nothing, so naturally the continuation takes no parameter either:
using NextRet = std::invoke_result_t<NextType>;Here std::invoke_result_t<NextType> deduces "call NextType with an empty parameter list, and see what comes back". The lambda body is just two steps: first run the original callback and toss the result away; then fish out the continuation and run it, also with no arguments:
std::move(self).run(std::forward<FuncArgs>(args)...);
return std::invoke(std::move(cont));The lambda capture: the heart of ownership
[self = std::move(*this), cont = std::forward<Next>(next)]self = std::move(*this) is the vital organ of the whole ownership chain. It moves the current OnceCallback's entire estate — func_, status_, token_, not one left behind — into the lambda's closure. Once the move is done, the current object is a hollowed-out shell: func_ and token_ no longer belong to it. cont = std::forward<Next>(next) takes the continuation in as well, with std::forward standing guard over next's original value category: an rvalue gets moved, an lvalue gets copied.
This lambda is finally handed to a fresh OnceCallback<NextRet(FuncArgs...)> constructor and tucked into its std::move_only_function. The type-erasure machinery means that whatever shape the lambda happens to take, it can be folded into the same shell.
Multi-stage pipelines
then() can naturally keep linking on, section by section, into a multi-stage pipeline:
using namespace tamcpp::chrome;
auto pipeline = OnceCallback<int(int)>([](int x) {
return x * 2;
}).then([](int x) {
return x + 10;
}).then([](int x) {
return std::to_string(x);
});
std::string result = std::move(pipeline).run(5);
// 5 * 2 = 10, 10 + 10 = 20, to_string(20) = "20"Every call to then() casts a new OnceCallback with a closure nested inside it that captured the previous step's callback. The moment the outermost run() fires, execution unfolds layer by layer like a nesting doll: the outermost one is run() → its own lambda executes → inside that lambda, std::move(self).run() is called on the next layer in → and the layer beyond that → drilling all the way down.
There is a price, though. Every extra stage of then() adds one more indirection through std::move_only_function. For a two- or three-stage pipeline that overhead is entirely negligible; if you really stack up ten-plus stages, the nesting grows deep enough that a flattened pipeline structure is probably called for — but that is far afield from our present topic, so we'll set it aside for now.
A few easy places to trip up
mutable is not optional
Inside the lambda we call std::move(self).run(), and that genuinely mutates self's state — flipping the status from kValid over to kConsumed. Without mutable on the lambda, self is a const reference inside, and messing with a const object is something the compiler catches every single time — a hard error on the spot.
The state of self = std::move(*this)
After the move, the original OnceCallback's func_ and token_ have already run away from home, leaving it in a "moved-from" state. Nobody explicitly dials status_ back to kEmpty, so the old value still hangs there. But with func_ empty, the shell is effectively dead, and anyone who touches it again is in undefined-behavior territory. Thankfully, that && qualifier on then() guards the gate: the caller never gets a chance to keep using the original object after then().
Why std::invoke instead of calling directly
cont is usually just a lambda, and writing cont(mid) directly would run fine. But if someday someone passes in a member function pointer as the continuation, the direct-call syntax dies on the spot — std::invoke does not. Routing everything through std::invoke buys exactly that: whatever tool the other side brings, our machinery can catch it.