Implementing a std::function
look-alike#
In this case study, we will be implementing an approximation of
std::function
with tanuki. This will not be a full drop-in
replacement for std::function
, because we aim to write an implementation which, contrary to std::function
and similarly to std::move_only_function,
correctly respects const-correctness. Additionally, our polymorphic function wrapper (which we will be naming callable
)
will also support wrapping references (thus providing functionality similar to
std::function_ref).
The interface and its implementation#
Let us begin by taking a look at the interface:
template <typename R, typename... Args>
struct callable_iface {
virtual R operator()(Args... args) const = 0;
virtual explicit operator bool() const noexcept = 0;
template <typename Base, typename Holder, typename T>
using impl = callable_iface_impl<Base, Holder, T, R, Args...>;
};
Note that this interface depends on one or more template parameters:
the return type R
and the argument type(s) Args
.
The two member functions in the interface are the call operator and the conversion
operator to bool
, which will be used to detect the empty state.
Note that the call operator is marked const
- our objective
in this example is to implement an immutable function wrapper.
The default implementation of the interface is left empty (so that, as explained in the tutorial, this will be an invalid implementation):
template <typename Base, typename Holder, typename T, typename R, typename... Args>
struct callable_iface_impl {
};
Note that the R
and Args
template arguments are appended after the
mandatory Base
, Holder
and T
arguments.
Next, we get to the “real” interface implementation. Let us parse it a bit at a time, beginning with the declaration:
template <typename Base, typename Holder, typename T, typename R, typename... Args>
requires std::is_invocable_r_v<R, const tanuki::unwrap_cvref_t<T> &, Args...> && std::copy_constructible<T>
struct callable_iface_impl<Base, Holder, T, R, Args...> : public Base {
We can see that the requires
clause enables this implementation for all
value (or reference) types which are
invocable and
copy-constructible.
As mentioned earlier, our objective is to implement an immutable function wrapper,
and thus we check for invocability on a const
reference to the value type.
That is, if the value type provides non-const
call operator, the implementation
will be disabled.
Next, we see how the call operator is implemented:
R operator()(Args... args) const final
{
using unrefT = tanuki::unwrap_cvref_t<T>;
// Check for null function pointer.
if constexpr (std::is_pointer_v<unrefT> || std::is_member_pointer_v<unrefT>) {
if (getval<Holder>(this) == nullptr) {
throw std::bad_function_call{};
}
}
if constexpr (std::is_same_v<R, void>) {
static_cast<void>(std::invoke(getval<Holder>(this), std::forward<Args>(args)...));
} else {
return std::invoke(getval<Holder>(this), std::forward<Args>(args)...);
}
}
The call operator will ultimately invoke (via std::invoke)
the type-erased value with the supplied arguments args
(possibly going through a cast to void
in order
to emulate C++23’s std::invoke_r()).
Before doing that, however, we must check that the object we are invoking is not a null pointer to
a (member) function - if that is the case, we will be raising an exception rather than incurring
in undefined behaviour.
Let us take a look at the bool
conversion operator now:
explicit operator bool() const noexcept final
{
using unrefT = tanuki::unwrap_cvref_t<T>;
if constexpr (std::is_pointer_v<unrefT> || std::is_member_pointer_v<unrefT>) {
return getval<Holder>(this) != nullptr;
} else if constexpr (is_any_callable<unrefT>::value || is_any_std_func<unrefT>::value) {
return static_cast<bool>(getval<Holder>(this));
} else {
return true;
}
}
This operator must detect if the internal type-erased value is in an empty state - in which
case the operator will return false
. What an “empty state” is depends on
the value type:
if the value is a pointer, then the empty state is represented by a
nullptr
;if the value is a
std::function
or acallable
(yes, we can put type-erased containers inside other type-erased containers, Russian doll-style), then the empty state is detected by invoking the value’sbool
conversion operator [1];otherwise, we assume the internal value is not in the empty state.
Footnotes
The reference interface#
We can now move on to the implementation of the reference interface.
The reference interface for our callable
class will have to implement a few bits of additional logic,
and thus we cannot simply use the TANUKI_REF_IFACE_MEMFUN
macro to just forward
the API calls into the interface.
Let us examine it a bit at a time:
// Reference interface.
template <typename R, typename... Args>
struct callable_ref_iface {
template <typename Wrap>
struct impl {
using result_type = R;
The first thing we do is to provide a result_type
alias for the return type R
. We do this in order
to emulate the behaviour of std::function
(which also provides such an alias) and to show that the reference
interface, in addition to providing member functions, can also provide type aliases (remember that
wrap
will be inheriting from the reference interface).
Next, we take a look at the call operator:
template <typename JustWrap = Wrap, typename... FArgs>
auto operator()(FArgs &&...fargs) const
-> decltype(iface_ptr(*static_cast<const JustWrap *>(this))->operator()(std::forward<FArgs>(fargs)...))
{
// NOTE: a wrap in the invalid state is considered empty.
if (is_invalid(*static_cast<const Wrap *>(this))) {
throw std::bad_function_call{};
}
return iface_ptr(*static_cast<const Wrap *>(this))->operator()(std::forward<FArgs>(fargs)...);
}
There is quite a bit going on here visually, but the basic gist is this: before invoking the call operator of the interface,
we need to ensure that the callable
object is not in the invalid state (this could
happen, for instance, on a moved-from callable
). If the callable
is in the invalid state, we
will raise an error.
The implementation of the call operator of the reference interface employs, as usual, the
CRTP to reach
the pointer to the interface via the iface_ptr()
function, which is then used to invoke
the interface’s call operator. The arguments are perfectly forwarded, and expression SFINAE is used
(via the trailing decltype(...)
) to disable the call operator if it is malformed.
Note that the visual complexity of this call operator is mainly due to CRTP limitations when employing C++20. In C++23, the call operator could be simplified to something like this:
template <typename Wrap, typename... FArgs>
auto operator()(this const Wrap &self,
FArgs &&...fargs) -> decltype(iface_ptr(self)->operator()(std::forward<FArgs>(fargs)...))
{
if (is_invalid(self)) {
throw std::bad_function_call{};
}
return iface_ptr(self)->operator()(std::forward<FArgs>(fargs)...);
}
Next, we take a look at the bool
conversion operator:
explicit operator bool() const noexcept
{
// NOTE: a wrap in the invalid state is considered empty.
if (is_invalid(*static_cast<const Wrap *>(this))) {
return false;
} else {
return static_cast<bool>(*iface_ptr(*static_cast<const Wrap *>(this)));
}
}
The idea here is the same: if the callable
object is in the invalid state, then
the bool
conversion operator will return false
. Otherwise, the bool
conversion operator of
the interface will be invoked.
In other words, with this implementation of the reference interface, we will be able to detect
if a callable
is empty either because the type-erased value is empty or the wrapping object
is in the invalid state.
Configuration#
Let us move on now to the configuration of the callable
wrap:
// Configuration of the callable wrap.
template <typename R, typename... Args>
inline constexpr auto callable_wrap_config = tanuki::config<R (*)(Args...), callable_ref_iface<R, Args...>>{
.static_size = tanuki::holder_size<R (*)(Args...), callable_iface<R, Args...>>,
.static_align = tanuki::holder_align<R (*)(Args...), callable_iface<R, Args...>>,
.pointer_interface = false,
.explicit_ctor = tanuki::wrap_ctor::always_implicit};
The configuration object includes the following custom settings:
the function pointer type
R (*)(Args...)
is used as default value type - this means that a default-initialisedcallable
will contain a null function pointer. This matches the behaviour ofstd::function
, which default-initialises to an empty object;the reference interface is set to
callable_ref_iface
;the
config::static_size
andconfig::static_align
settings are set up so that thecallable
wrap is guaranteed to be able to store pointers (and smaller objects) in static storage. This again matches the guarantees ofstd::function
;the pointer interface is disabled via
config::pointer_interface
;the generic constructors of
callable
are marked as implicit, again to in order to match the behaviour ofstd::function
.
We are now ready to define our callable
wrap:
// Definition of the callable wrap.
template <typename R, typename... Args>
using callable_wrap_t = tanuki::wrap<callable_iface<R, Args...>, callable_wrap_config<R, Args...>>;
Finally, we provide some syntactic sugar:
template <typename T>
struct callable_impl {
};
template <typename R, typename... Args>
struct callable_impl<R(Args...) const> {
using type = callable_wrap_t<R, Args...>;
};
// Definition of the callable object.
template <typename T>
requires(requires() { typename callable_impl<T>::type; })
using callable = typename callable_impl<T>::type;
We will now be able to take our callable
out for a spin.
Sample usage#
Let us begin with a few callable
instances constructed into the empty state:
assert(!callable<void() const>{});
assert(!callable<void() const>{static_cast<void (*)()>(nullptr)});
assert(!callable<void() const>{std::function<void()>{}});
assert(!callable<void() const>{callable<int() const>{}});
We can see that in all these cases the bool
conversion operator of callable
returns false
, as all these objects are constructed into the empty state either
by default construction or by construction from an empty object (e.g., a null function pointer).
Next, let us see an example with a lambda:
auto lambda_double = [](int n) { return n * 2; };
callable<int(int) const> c0 = lambda_double;
assert(c0(2) == 4);
Here, a copy of lambda_double
is stored in c0
. Let us see an example storing a reference
to lambda_double
instead:
callable<int(int) const> c0_ref = std::ref(lambda_double);
assert(c0_ref(2) == 4);
assert(&value_ref<std::reference_wrapper<decltype(lambda_double)>>(c0_ref).get() == &lambda_double);
The second assert()
confirms that a reference to lambda_double
(rather than a copy) has been
captured in c0_ref
.
Because our callable
has been designed to be immutable, we cannot construct an instance
from a mutable lambda:
auto mutable_lambda = [m = 5](int n) mutable {
m = m + n;
return n * 2;
};
assert((!std::constructible_from<callable<int(int) const>, decltype(mutable_lambda)>));
Finally, an example with a plain old function:
int doubler(int n)
{
return n * 2;
}
callable<int(int) const> c1 = doubler;
assert(c1(2) == 4);
Thie example shows how wrap
supports construction directly from a function type,
without requiring conversion to a function pointer.
Full code listing#
#include <cassert>
#include <concepts>
#include <functional>
#include <type_traits>
#include <utility>
#include <tanuki/tanuki.hpp>
// Machinery to detect std::function.
template <typename>
struct is_any_std_func : std::false_type {
};
template <typename R, typename... Args>
struct is_any_std_func<std::function<R(Args...)>> : std::true_type {
};
// Machinery to detect callable instances.
template <typename>
struct is_any_callable : std::false_type {
};
template <typename Base, typename Holder, typename T, typename R, typename... Args>
struct callable_iface_impl {
};
template <typename R, typename... Args>
struct callable_iface {
virtual R operator()(Args... args) const = 0;
virtual explicit operator bool() const noexcept = 0;
template <typename Base, typename Holder, typename T>
using impl = callable_iface_impl<Base, Holder, T, R, Args...>;
};
template <typename Base, typename Holder, typename T, typename R, typename... Args>
requires std::is_invocable_r_v<R, const tanuki::unwrap_cvref_t<T> &, Args...> && std::copy_constructible<T>
struct callable_iface_impl<Base, Holder, T, R, Args...> : public Base {
R operator()(Args... args) const final
{
using unrefT = tanuki::unwrap_cvref_t<T>;
// Check for null function pointer.
if constexpr (std::is_pointer_v<unrefT> || std::is_member_pointer_v<unrefT>) {
if (getval<Holder>(this) == nullptr) {
throw std::bad_function_call{};
}
}
if constexpr (std::is_same_v<R, void>) {
static_cast<void>(std::invoke(getval<Holder>(this), std::forward<Args>(args)...));
} else {
return std::invoke(getval<Holder>(this), std::forward<Args>(args)...);
}
}
explicit operator bool() const noexcept final
{
using unrefT = tanuki::unwrap_cvref_t<T>;
if constexpr (std::is_pointer_v<unrefT> || std::is_member_pointer_v<unrefT>) {
return getval<Holder>(this) != nullptr;
} else if constexpr (is_any_callable<unrefT>::value || is_any_std_func<unrefT>::value) {
return static_cast<bool>(getval<Holder>(this));
} else {
return true;
}
}
};
// Reference interface.
template <typename R, typename... Args>
struct callable_ref_iface {
template <typename Wrap>
struct impl {
using result_type = R;
template <typename JustWrap = Wrap, typename... FArgs>
auto operator()(FArgs &&...fargs) const
-> decltype(iface_ptr(*static_cast<const JustWrap *>(this))->operator()(std::forward<FArgs>(fargs)...))
{
// NOTE: a wrap in the invalid state is considered empty.
if (is_invalid(*static_cast<const Wrap *>(this))) {
throw std::bad_function_call{};
}
return iface_ptr(*static_cast<const Wrap *>(this))->operator()(std::forward<FArgs>(fargs)...);
}
explicit operator bool() const noexcept
{
// NOTE: a wrap in the invalid state is considered empty.
if (is_invalid(*static_cast<const Wrap *>(this))) {
return false;
} else {
return static_cast<bool>(*iface_ptr(*static_cast<const Wrap *>(this)));
}
}
};
};
// Configuration of the callable wrap.
template <typename R, typename... Args>
inline constexpr auto callable_wrap_config = tanuki::config<R (*)(Args...), callable_ref_iface<R, Args...>>{
.static_size = tanuki::holder_size<R (*)(Args...), callable_iface<R, Args...>>,
.static_align = tanuki::holder_align<R (*)(Args...), callable_iface<R, Args...>>,
.pointer_interface = false,
.explicit_ctor = tanuki::wrap_ctor::always_implicit};
// Definition of the callable wrap.
template <typename R, typename... Args>
using callable_wrap_t = tanuki::wrap<callable_iface<R, Args...>, callable_wrap_config<R, Args...>>;
// Specialise is_any_callable to detect callables.
template <typename R, typename... Args>
struct is_any_callable<callable_wrap_t<R, Args...>> : std::true_type {
};
template <typename T>
struct callable_impl {
};
template <typename R, typename... Args>
struct callable_impl<R(Args...) const> {
using type = callable_wrap_t<R, Args...>;
};
// Definition of the callable object.
template <typename T>
requires(requires() { typename callable_impl<T>::type; })
using callable = typename callable_impl<T>::type;
int doubler(int n)
{
return n * 2;
}
int main()
{
assert(!callable<void() const>{});
assert(!callable<void() const>{static_cast<void (*)()>(nullptr)});
assert(!callable<void() const>{std::function<void()>{}});
assert(!callable<void() const>{callable<int() const>{}});
auto lambda_double = [](int n) { return n * 2; };
callable<int(int) const> c0 = lambda_double;
assert(c0(2) == 4);
callable<int(int) const> c0_ref = std::ref(lambda_double);
assert(c0_ref(2) == 4);
assert(&value_ref<std::reference_wrapper<decltype(lambda_double)>>(c0_ref).get() == &lambda_double);
auto mutable_lambda = [m = 5](int n) mutable {
m = m + n;
return n * 2;
};
assert((!std::constructible_from<callable<int(int) const>, decltype(mutable_lambda)>));
callable<int(int) const> c1 = doubler;
assert(c1(2) == 4);
}