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 a callable (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’s bool 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-initialised callable will contain a null function pointer. This matches the behaviour of std::function, which default-initialises to an empty object;

  • the reference interface is set to callable_ref_iface;

  • the config::static_size and config::static_align settings are set up so that the callable wrap is guaranteed to be able to store pointers (and smaller objects) in static storage. This again matches the guarantees of std::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 of std::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);
}