Prior Notification
- This follows a previous review of mine that addressed the core helper function named
make_skippable
. - The composition implementation presented here is heavily inspired by another review that already introduced the basic concept: Function composition in C++. Thanks to Nestor for providing the pattern.
The Problem
This concerns the construction of data processing pipelines. Given a series of callables, each designed to transform input into output, the goal is to find an intuitive and straightforward method to link these callables together in a chain, by creating a single, unified callable, the composition. The composition should be capable of accepting the same argument as the first callable in the chain and should return the output of the last callable in the chain. That‘s basically it. Below is a detailed list of requirements that any effective solution should satisfy.
- Language Standard: c++17
- Ease of Use: constructing a composition out of given callables should be as straightforward as possible
- Supported Callable Types: functions, lambdas, and functors
- Callable Signatures: All callables to be composed must accept a single argument and return a non-void type. Callables that return
void
are excluded as they cannot be chained in data processing flows. - Composition Object: The resulting composition is a callable itself, as such it it assignable to
std::function
. - Breakable Chain Mechanism: Any callable may break the chain by not providing a result. In consequence the subsequent callables are not executed (skipped). Not providing a result can be considered a usual outcome and does not need to be an error.
- Error Handling: The implementation should be exception-safe. Exceptions thrown by any callable in the chain should be propagated to the caller of the composition.
- Generic and Overloaded Callable Support: Generic lambdas, generic functors and overloaded functors shall be supported as callables to be composed. This means a single composed chain might take different input argument types that might even result in different output types. Thus, the composition signature is as flexibel as the signatures of the composed callables itself (see Generic Lambda Example below).
- Compile-Time Signature Validation: In case of mismatching callable signatures, meaningful compile time error messages should get provided.
- Move Semantics and Perfect Forwarding: The implementation must fully support move semantics for callables and their arguments.
My Solution
Basically my solution provides a function named compose
that can be used as follows.
Basic Usage Example
auto lambda_1 = [](bool flag) -> int { return flag ? 7 : 0; };
auto lambda_2 = [](int value) -> std::string { return std::to_string(value); };
auto lambda_3 = [](const std::string& string) -> std::string { return string + string; };
auto composition = compose(lambda_1, lambda_2, lambda_3);
std::optional<std::string> result = composition(true);
assert(result = “77”)
Generic Lambda Example
auto generic_lambda = DisablingOptionalFn{[](auto arg) { return arg; }};
auto composition = compose(generic_lambda, generic_lambda);
auto result1 = composition(true);
assert(result1 == true);
auto result2 = composition(“string”);
assert(result2 == "string");
Chain Breaking Example
auto breaking_lambda = [](bool) -> std::optional<int> { return std::nullopt; };
auto subsequent_lambda = [](int) -> int { return 0; };
auto composition = compose(breaking_lambda, subsequent_lambda, subsequent_lambda);
std::optional<int> result = composition(true);
assert(!result.has_value());
The Implementation
// A type trait that checks if a given type is std::optional.
template <typename>
struct IsOptional : std::false_type {};
template <typename T>
struct IsOptional<std::optional<T>> : std::true_type {};
// A type trait that wraps a given type in std::optional if it isn't already.
template <typename T>
struct EnsureOptional {
using Type = std::optional<T>;
};
template <typename U>
struct EnsureOptional<std::optional<U>> {
using Type = std::optional<U>;
};
// A function that takes any value and ensures it is wrapped in std::optional.
template <typename TArg>
auto ensure_optional(TArg&& arg) {
using OptionalType = typename EnsureOptional<std::decay_t<TArg>>::Type;
return OptionalType{std::forward<TArg>(arg)};
}
// A type trait providing the return type of TFn including compile-time checks
template <typename TFn, typename TArg>
struct InvokeResult {
static_assert(std::is_invocable_v<TFn, TArg>, "Callable TFn does not support arguments of type TArg");
static_assert(not std::is_invocable_v<TFn, std::optional<TArg>>,
"Callable TFn may not support an argument of type std::optional");
using Type = typename std::invoke_result<TFn, TArg>::type;
static_assert(not std::is_void_v<Type>, "Result of TFn may not be void");
};
// Helper template function that transforms a given function (Fn) into another one (FnSkippable). FnSkippable expectes
// Fn's argument wrapped in a std::optional. Moreover, FnSkippable returns Fn's result wrapped into std::optional,
// unless it is not already. When calling FnSkippable, Fn is just executed unless FnSkippable is called with a
// std::nullopt. In this case, Fn is not executed (skipped) and FnSkippable simply returns a std::nullopt.
template <typename TFn>
auto make_skippable(TFn&& fn) {
return [fn = std::forward<TFn>(fn)](auto&& optional_arg) mutable {
using OptionalArg = std::decay_t<decltype(optional_arg)>;
using ValueArg = typename OptionalArg::value_type;
using FnResult = typename InvokeResult<TFn, ValueArg>::Type;
using OptionalFnResult = typename EnsureOptional<FnResult>::Type;
if (optional_arg.has_value()) {
auto&& unwrapped_value = std::forward<OptionalArg>(optional_arg).value();
return OptionalFnResult{fn(std::forward<decltype(unwrapped_value)>(unwrapped_value))};
}
// skip fn
return OptionalFnResult{std::nullopt};
};
}
// Helper function template overload that terminates the recursion. The given callable essentially represents the
// composition. To make sure that the composition is always called with an optional argument (as expected by the
// skippable callables) ensure_optional is applied to the argument.
template <typename TFn>
auto compose_skippables(TFn&& fn) {
return [fn = std::forward<TFn>(fn)](auto&& arg) mutable {
return fn(ensure_optional(std::forward<decltype(arg)>(arg)));
};
}
// Helper function template overload that creates a composition out of an arbitrary number of given callables. It
// composes the first two callables into a new lambda that, when called, executes the first callable and passes its
// result to the second callable. The function then recursively composes this combined lambda with the rest of the
// provided callables.
template <typename TFn1, typename TFn2, typename... TFnOthers>
auto compose_skippables(TFn1&& fn_1, TFn2&& fn_2, TFnOthers&&... fn_others) {
auto chained_fn = [fn_1 = std::forward<TFn1>(fn_1), fn_2 = std::forward<TFn2>(fn_2)](auto&& arg) mutable {
return fn_2(fn_1(std::forward<decltype(arg)>(arg)));
};
return compose_skippables(std::move(chained_fn), std::forward<TFnOthers>(fn_others)...);
}
// Function template that creates a composition out of the given callables. It first makes each callable "skippable" and
// then composes them into a single callable chain using compose_skippables.
template <typename... TFns>
auto compose(TFns&&... fns) {
return compose_skippables(make_skippable(std::forward<TFns>(fns))...);
}
// Helper template to remove support for optional arguments for TFn
template <typename TFn>
struct DisablingOptionalArgumentFn {
explicit DisablingOptionalArgumentFn(TFn&& fn) : m_fn{std::forward<TFn>(fn)} {}
template <typename TArg, typename = std::enable_if_t<not IsOptional<TArg>::value>>
auto operator()(TArg&& arg) {
return m_fn(std::forward<TArg>(arg));
}
private:
TFn m_fn;
};
Additional Remarks
- Decision for std::optional: To enable callables to not return a result
std::optional
was choosen, even thoughstd::expect
being the preferable option, which is not available in c++17. - No Monadic Operations: It's recognized that c++23 introduced monadic operations for
std::optional
andstd::except
, enabling the construction of pipelines through their application. However, it's important to note that these do not offer the straightforward composition functionality desired in this context. - No Support for Callables Accepting std::optional: As pointed out in the previous review by indi, supporting callables that accept a std::optional as argument raises the question how to make them skippable in a reasonable way (see his question “What should I get in that last quadrant?”). Ease of use is a core goal here, so to keep it simple I decided to even not raise this question and therefore dropped support for callables accepting a
std::optional
. Astatic_assert
was added accordingly. To address this, theDisablingOptionalArgumentFn
helper template has been introduced, enabling users to adapt generic lambdas for use with this implementation, thereby raising awareness of this design choice. See Generic Lambda Example above.
How do you feel about this implementation? I'm open to any feedback you might have :-)
Please find the implementation including tests at godbolt.