Reference vs pointer interface#

In the previous tutorial we showed several ways in which type-erased interfaces can be implemented in tanuki. We also showed how the interface member functions can be invoked directly from the wrap class via the arrow operator ->:

using foo1_wrap = tanuki::wrap<foo1_iface>;

foo1_wrap w1(foo_model{});

// Invoke the interface's foo() member function
// directly from the wrap class.
w1->foo();

Relying on the arrow operator -> to access the member functions can however be problematic.

To begin with, the arrow operator -> is usually associated with pointers, but, as we mentioned previously, the wrap class employs by default value semantics and not pointer semantics. Although the usage of the arrow operator with value semantics is not unprecedented (e.g., see std::optional), it may be a source of confusion.

Secondly, and more importantly, in generic algorithms and data structures it is often required to invoke member functions via the dot operator ., rather than via the arrow operator. For instance, if we wanted to write the type-erased version of a standard range, we would need to be able to access the begin/end functions as .begin() and .end(), rather than ->begin() and ->end().

The not-so-good news is that at this time the C++ language offers no clean solution to automatically enable dot-style access to the member functions of the interface from the wrap class. This would require dot operator overloading or perhaps reflection/metaclasses, neither of which are currently available.

The better news is that tanuki provides a mechanism to deactivate the pointer interface (i.e., operator ->) and activate a reference interface instead (i.e., dot-style access to the member functions). Implementing a reference interface will require a certain amount of repetition that can be partially alleviated via the use of macros.

The following sections explain how to implement reference interfaces. Two APIs can be used: the first one is always available, the second one is a cleaner and less verbose alternative that however requires C++23.

Reference interfaces in C++20#

Consider the simple interface from the previous tutorial, its implementation and a foo_model class:

template <typename Base, typename Holder, typename T>
struct foo_iface_impl : public Base {
    void foo() const override
    {
        std::cout << "foo_iface_impl calling foo()\n";
        getval<Holder>(this).foo();
    }
};

struct foo_iface {
    virtual void foo() const = 0;

    template <typename Base, typename Holder, typename T>
    using impl = foo_iface_impl<Base, Holder, T>;
};

struct foo_model {
    void foo() const {}
};

Let us see first a macro-based implementation of a reference interface:

struct foo_ref_iface1 {
    template <typename Wrap>
    struct impl {
        TANUKI_REF_IFACE_MEMFUN(foo)
    };
};

A reference interface must be a class which defines in its scope an impl class template depending exactly on one parameter, conventionally named Wrap. The impl class is the actual reference interface, and the Wrap parameter represents the wrap class to which the reference interface is applied. impl will be used as a static mixin to augment the wrap class.

In the body of the impl class, we should declare and implement the list of member functions of foo_iface that we want to be able to access via the dot operator in the wrap class. This is most easily accomplished with the help of the TANUKI_REF_IFACE_MEMFUN macro, which requires only the name of the member function (foo, in this specific case).

In order to understand what is going on behind the scenes, let us also see an example of an equivalent reference interface which does not use the TANUKI_REF_IFACE_MEMFUN macro:

struct foo_ref_iface2 {
    template <typename Wrap>
    struct impl {
        void foo() const
        {
            iface_ptr(*static_cast<const Wrap *>(this))->foo();
        }
    };
};

Here is what is going on: tanuki makes the wrap class inherit from foo_ref_iface2::impl, so that, via the curiously recurring template pattern (CRTP), we can invoke the iface_ptr() function on the wrap object to access a pointer to the foo_iface interface, via which we finally invoke the foo() member function. Phew!

The TANUKI_REF_IFACE_MEMFUN macro works by defining several variadic overloads for the foo() member function, automatically disabling the invalid ones via expression SFINAE. Although TANUKI_REF_IFACE_MEMFUN is not perfect (e.g., it cannot be used with certain overloadable operators), you should consider using it if you can stomach macros.

After the definition of the reference interfaces, we need to configure the wrap class to make use of them. This is accomplished by defining custom config instances and using them in the wrap class. For instance, for the macro-based reference interface we first define a custom configuration called foo_config1:

inline constexpr auto foo_config1 = tanuki::config<void, foo_ref_iface1>{.pointer_interface = false};

The config class is templated over two types. Ignoring the first one for the time being (its meaning will be explained later), the second parameter is the reference interface, which we set to foo_ref_iface1 to select the macro-based reference interface. We also switch off the pointer interface in wrap by setting to false the pointer_interface configuration setting – this will ensure that access to the interface functions via the arrow operator is disabled.

We can now use the custom configuration instance in the definition of the wrap class:

    using foo_wrap1 = tanuki::wrap<foo_iface, foo_config1>;

And we can confirm that indeed we can now invoke the foo() member function via the dot operator:

    foo_wrap1 w1(foo_model{});
    w1.foo();
foo_iface_impl calling foo()

We can use the non-macro-based foo_ref_iface2 reference interface in exactly the same way. First, we define a custom configuration instance:

inline constexpr auto foo_config2 = tanuki::config<void, foo_ref_iface2>{.pointer_interface = false};

Second, we can use the custom configuration instance to define another wrap type:

    using foo_wrap2 = tanuki::wrap<foo_iface, foo_config2>;

    foo_wrap2 w2(foo_model{});
    w2.foo();
foo_iface_impl calling foo()

Reference interfaces in C++23#

An alternative API for the definition of reference interfaces is available if your compiler supports C++23 (more specifically, the so-called “deducing this” feature). With the alternative API, it is not necessary any more to define a nested impl template, and we can define the member function wrappers directly in the body of the class instead, like this:

struct foo_ref_iface3 {
    TANUKI_REF_IFACE_MEMFUN2(foo)
};

Note that, in the C++23 API, we must use the TANUKI_REF_IFACE_MEMFUN2 macro, rather than TANUKI_REF_IFACE_MEMFUN.

If you cannot or do not want to use the TANUKI_REF_IFACE_MEMFUN2 macro, here is the implementation of a C++23 reference interface without macros:

struct foo_ref_iface4 {
    template <typename Wrap>
    void foo(this const Wrap &self)
    {
        iface_ptr(self)->foo();
    }
};

In other words, the “deducing this” C++23 feature allows us to avoid having to perform the CRTP cast manually, and we can invoke the iface_ptr() function directly on the self/this argument instead.

We can then proceed as usual with the definition of the custom configurations using the new reference interfaces:

inline constexpr auto foo_config3 = tanuki::config<void, foo_ref_iface3>{.pointer_interface = false};

inline constexpr auto foo_config4 = tanuki::config<void, foo_ref_iface4>{.pointer_interface = false};

The definition and usage of the wrap instances is identical to the C++20 API:

    using foo_wrap3 = tanuki::wrap<foo_iface, foo_config3>;

    foo_wrap3 w3(foo_model{});
    w3.foo();

    using foo_wrap4 = tanuki::wrap<foo_iface, foo_config4>;

    foo_wrap4 w4(foo_model{});
    w4.foo();
foo_iface_impl calling foo()
foo_iface_impl calling foo()

Note that the C++20 reference interface API is still available when using C++23 – tanuki will detect whether or not the nested template impl is present and will auto-select the API accordingly.

Tips & tricks#

Reference interfaces are useful beyond just enabling dot-style access to the member functions. For instance, they also allow to define nested types/typedefs/aliases and inline friend functions and operators that will be accessible via ADL.

On the other hand, it is also possible to design an API around the wrap class which does not employ member functions and which thus needs neither a pointer nor a reference interface. In this usage mode, the member functions defined in a type-erased interface are used as an implementation detail for free functions, rather than being invoked directly.

Full code listing#

#include <iostream>

#include <tanuki/tanuki.hpp>

template <typename Base, typename Holder, typename T>
struct foo_iface_impl : public Base {
    void foo() const override
    {
        std::cout << "foo_iface_impl calling foo()\n";
        getval<Holder>(this).foo();
    }
};

struct foo_iface {
    virtual void foo() const = 0;

    template <typename Base, typename Holder, typename T>
    using impl = foo_iface_impl<Base, Holder, T>;
};

struct foo_model {
    void foo() const {}
};

struct foo_ref_iface1 {
    template <typename Wrap>
    struct impl {
        TANUKI_REF_IFACE_MEMFUN(foo)
    };
};

struct foo_ref_iface2 {
    template <typename Wrap>
    struct impl {
        void foo() const
        {
            iface_ptr(*static_cast<const Wrap *>(this))->foo();
        }
    };
};

inline constexpr auto foo_config1 = tanuki::config<void, foo_ref_iface1>{.pointer_interface = false};

inline constexpr auto foo_config2 = tanuki::config<void, foo_ref_iface2>{.pointer_interface = false};

#if defined(TANUKI_HAVE_EXPLICIT_THIS)

struct foo_ref_iface3 {
    TANUKI_REF_IFACE_MEMFUN2(foo)
};

struct foo_ref_iface4 {
    template <typename Wrap>
    void foo(this const Wrap &self)
    {
        iface_ptr(self)->foo();
    }
};

inline constexpr auto foo_config3 = tanuki::config<void, foo_ref_iface3>{.pointer_interface = false};

inline constexpr auto foo_config4 = tanuki::config<void, foo_ref_iface4>{.pointer_interface = false};

#endif

int main()
{
    using foo_wrap1 = tanuki::wrap<foo_iface, foo_config1>;

    foo_wrap1 w1(foo_model{});
    w1.foo();

    using foo_wrap2 = tanuki::wrap<foo_iface, foo_config2>;

    foo_wrap2 w2(foo_model{});
    w2.foo();

#if defined(TANUKI_HAVE_EXPLICIT_THIS)

    using foo_wrap3 = tanuki::wrap<foo_iface, foo_config3>;

    foo_wrap3 w3(foo_model{});
    w3.foo();

    using foo_wrap4 = tanuki::wrap<foo_iface, foo_config4>;

    foo_wrap4 w4(foo_model{});
    w4.foo();

#endif
}