P0146 describes the issue at hand pretty well: in generic code, we often want to deal with functions that can return any type - and we don't actually care what that type is. Example from the paper:
// Invoke a Callable, logging its arguments and return value.
// Requires an exact match of Callable&&'s pseudo function type and R(P...).
template<class R, class... P, class Callable>
R invoke_and_log(callable_log<R(P...)>& log, Callable&& callable,
std::add_rvalue_reference_t<P>... args) {
log.log_arguments(args...);
R result = std::invoke(std::forward<Callable>(callable),
std::forward<P>(args)...);
log.log_result(result);
return result;
}
This is correct for all R
except void
. For void
, specifically, we need dedicated handling:
if constexpr (std::is_void_v<R>) {
std::invoke(std::forward<Callable>(callable),
std::forward<P>(args)...);
log.log_result();
} else {
R result = std::invoke(std::forward<Callable>(callable),
std::forward<P>(args)...);
log.log_result(result);
return result;
}
This is tedious and error-prone. While P0146 proposed a language solution to this problem (letting the original code just work with R=void
), the paper also pointed out that this could be helped with a library-based solution.
This is such a (C++17) library-based solution.
It features the following:
-
a type,
vd::Void
, that is a regular unit type. -
metafunctions
vd::wrap_void
andvd::unwrap_void
that convertvoid
tovd::Void
and back. -
a function
vd::invoke
that is similar tostd::invoke
except that:- it returns
vd::Void
instead ofvoid
, where appropriate, and vd::invoke(f, vd::Void{})
is equivalent tovd::invoke(f)
(regardless of whetherf
is invocable withvd::Void
). See rationale.
- it returns
-
a metafunction
vd::void_result_t
that is tovd::invoke
whatstd::invoke_result_t
is tostd::invoke
, except that it still gives youvoid
(instead ofVoid
). See rationale. -
(on C++20) a concept
vd::invocable
that is tovd::invoke
whatstd::invocable
is tostd::invoke
This library allows the above code to be handled as:
template<class R, class... P, class Callable>
vd::wrap_void<R> invoke_and_log(
callable_log<R(P...)>& log, Callable&& callable,
std::add_rvalue_reference_t<P>... args) {
log.log_arguments(args...);
vd::wrap_void<R> result = vd::invoke(VD_FWD(callable), VD_FWD(args)...);
// either pass result in directly, if passing Void is acceptable
log.log_result(result);
// Or use vd::invoke again to be able to call log.log_result()
// in the void case.
// VD_LIFT is a macro that turns a name into a function object.
vd:invoke([&] VD_LIFT(log.log_result), result);
return result;
}
There is basically only one interesting design choice in this library (the rest
kind just falls out of wanting to solve the problem) and that is having
vd::invoke(f, vd::Void{})
fallback to trying f()
if f(vd::Void{})
is not a valid expression.
The reason for this (along with the corresponding unwrap in vd::void_result_t
)
is that it ends up being more useful for common use-cases. Such as:
template <class T>
class Optional {
union { vd::wrap_void<T> value_; };
bool engaged_;
public:
template <class F>
auto map(F f) const -> Optional<vd::void_result_t<F&, vd::wrap_void<T> const&>>
{
if (engaged_) {
return vd::invoke(f, value_);
} else {
return {};
}
}
};
For Optional<void>
(a seemingly pointless, yet nevertheless useful
specialization), this allows map
to take a nullary function. And for
Optional<T>
, if F
returns void
, we get back Optional<void>
. Everything
works out quite nicely.