A simple interface#
The minimal example shown in the previous tutorial was not very
interesting, as the interface we implemented did not define any behaviour other than
destruction. In this tutorial, we are going to take the next step and implement
a slightly more useful interface consisting of a single member function called foo()
.
Take 1: The basics#
Here is the first version of our interface:
struct foo1_iface {
virtual void foo() const = 0;
template <typename Base, typename Holder, typename T>
using impl = foo1_iface_impl<Base, Holder, T>;
};
Here is the declaration of a tanuki implementation for this interface:
template <typename Base, typename Holder, typename T>
struct foo1_iface_impl : public Base {
As explained in the previous tutorial, an interface implementation
must always derive from its Base
template parameter. Additionally, we must provide
an implementation for the void foo() const
function:
template <typename Base, typename Holder, typename T>
struct foo1_iface_impl : public Base {
void foo() const override
{
std::cout << "foo1_iface_impl calling foo()\n";
static_cast<const Holder *>(this)->_tanuki_value.foo();
}
};
The Holder
template parameter is a class defined in the tanuki library which stores the
value we are type-erasing as the _tanuki_value
data member. Holder
derives from foo1_iface_impl
and thus we can reach the _tanuki_value
data member via the cast
static_cast<const Holder *>(this)
leveraging the
curiously recurring template pattern (CRTP).
In other words, this implementation of the void foo() const
function will invoke
the foo()
member function of the type-erased value.
We can now proceed to define a class providing such a foo()
member function:
struct foo_model {
void foo() const
{
std::cout << "foo_model calling foo()\n";
}
};
We are now ready to define and use a type-erased wrapper:
using foo1_wrap = tanuki::wrap<foo1_iface>;
foo1_wrap w1(foo_model{});
w1->foo();
This code will produce the following output:
foo1_iface_impl calling foo()
foo_model calling foo()
That is, the foo1_wrap
wrapper provides access, via the arrow operator ->
,
to the foo1_iface_impl::foo()
member function, which in turn invokes
foo_model::foo()
.
Take 2: Introducing getval()
#
The use of the CRTP in the interface implementation can be a bit verbose and ugly.
tanuki provides a couple of helpers, called getval()
,
which can help reducing typing in an interface implementation. Let us see them
in action with a second version of the interface implementation:
template <typename Base, typename Holder, typename T>
struct foo2_iface_impl : public Base {
void foo() const override
{
std::cout << "foo2_iface_impl calling foo()\n";
getval<Holder>(this).foo();
}
};
Here we have replaced the explicit static_cast
with an invocation of getval()
,
which, behind the scenes, performs the CRTP downcast and returns a (const) reference to the
type-erased value.
Note that getval()
does a bit more than providing access to the type-erased value, as it will be explained
in a later tutorial. In any case, from now on, all interface implementations
shown in these tutorials will make use of getval()
in order to reduce typing.
Let us see the new interface implementation in action:
using foo2_wrap = tanuki::wrap<foo2_iface>;
foo2_wrap w2(foo_model{});
w2->foo();
foo2_iface_impl calling foo()
foo_model calling foo()
Take 3: Concept checking#
What happens though if we try to construct a foo2_wrap
from an object which does not provide a foo()
member
function (such as, e.g., an int
)? The compiler will loudly complain:
simple_interface.cpp:34:23: error: request for member ‘foo’ in [...] which is of non-class type ‘const int’
34 | _tanuki_value.foo();
This is of course not ideal, for at least a couple of reasons:
the error is generated deep in the bowels of the interface implementation, it would be better if we could catch the problem earlier and provide a more meaningful error message;
we might want to provide an alternative implementation of the interface for value types that do not have a
foo()
member function.
C++20 concepts are a powerful tool that allows us to address both of these issues in more than one way.
As a first approach, we are going to constrain our interface implementation to types that
provide a foo()
member function. In order to do this, we first introduce a fooable
concept:
template <typename T>
concept fooable = requires(const T &x) { x.foo(); };
This concept will be satisfied by all types which provide a const foo()
member function.
We then constrain the interface implementation to fooable
types via the requires
keyword:
template <typename Base, typename Holder, typename T>
requires fooable<T>
struct foo3_iface_impl : public Base {
void foo() const override
{
std::cout << "foo3_iface_impl calling foo()\n";
getval<Holder>(this).foo();
}
};
That is, the third template parameter T
in a tanuki interface implementation represents the type of the
value stored in the Holder
class, and we can thus use it to specialise and/or constrain interface implementations.
Here is the new interface implementation in action:
using foo3_wrap = tanuki::wrap<foo3_iface>;
foo3_wrap w3(foo_model{});
w3->foo();
foo3_iface_impl calling foo()
foo_model calling foo()
foo3_iface_impl
is similar to what Rust calls a
blanket implementation.
What happens now if we try to construct a foo3_wrap
from an int
? The error message given by the compiler changes:
simple_interface.cpp:84:21: error: no matching function for call to ‘tanuki::v1::wrap<foo3_iface>::wrap(int)’
[...]
1073 | wrap(T &&x) noexcept(noexcept(this->ctor_impl<detail::value_t_from_arg<T &&>>(std::forward<T>(x)))
| ^~~~
tanuki.hpp:1073:9: note: template argument deduction/substitution failed:
tanuki.hpp:1073:9: note: constraints not satisfied
That is, the constructor of the wrap
class now detects that int
does not have an
interface implementation, and as a consequence the compiler detects an error before trying to
invoke the (non-existing) foo()
member function on the int
. We can confirm
that the non-constructibility of foo3_wrap
from int
is detected at compile time
by checking the std::is_constructible
type trait:
std::cout << std::boolalpha;
std::cout << "Is foo3_wrap constructible from an int? " << std::is_constructible_v<foo3_wrap, int> << '\n';
Is foo3_wrap constructible from an int? false
This is all fine and dandy, but what if we wanted to provide an implementation of our interface also for int
? We can do this
with yet another (and final) variation on the theme.
Take 4: Empty implementations#
First off, we begin with an empty interface implementation:
template <typename Base, typename Holder, typename T>
struct foo4_iface_impl {
};
This is an invalid implementation because it does not derive from Base
.
Second, we add a constrained specialisation of the interface implementation
for fooable
types (that is, types providing a const foo()
member function):
template <typename Base, typename Holder, typename T>
requires fooable<T>
struct foo4_iface_impl<Base, Holder, T> : public Base {
void foo() const override
{
std::cout << "foo4_iface_impl calling foo()\n";
getval<Holder>(this).foo();
}
};
Finally, we add a specialisation of foo4_iface_impl
for int
:
template <typename Base, typename Holder>
struct foo4_iface_impl<Base, Holder, int> : public Base {
void foo() const override
{
std::cout << "foo4_iface_impl implementing foo() for the integer " << getval<Holder>(this) << "\n";
}
};
This implementation will just print to screen the value of the integer
when the foo()
function is invoked.
Let use see the new interface implementation in action:
using foo4_wrap = tanuki::wrap<foo4_iface>;
foo4_wrap w4(foo_model{});
w4->foo();
foo4_wrap w4a(42);
w4a->foo();
foo4_iface_impl calling foo()
foo_model calling foo()
foo4_iface_impl implementing foo() for the integer 42
It works! Bu what happens if we try to construct a foo4_wrap
from an object that is neither
fooable
nor an int
? The wrap
class will detect
that the interface implementation corresponding to an object of such type is empty (i.e., invalid),
and it will thus disable the constructor. We can confirm that this is the case by checking
the constructibility of foo4_wrap
from a float
(which is neither
fooable
nor an int
):
std::cout << "Is foo4_wrap constructible from a float? " << std::is_constructible_v<foo4_wrap, float> << '\n';
Is foo4_wrap constructible from a float? false
And that is enough for now!
Full code listing#
#include <iostream>
#include <tanuki/tanuki.hpp>
template <typename Base, typename Holder, typename T>
struct foo1_iface_impl : public Base {
void foo() const override
{
std::cout << "foo1_iface_impl calling foo()\n";
static_cast<const Holder *>(this)->_tanuki_value.foo();
}
};
struct foo1_iface {
virtual void foo() const = 0;
template <typename Base, typename Holder, typename T>
using impl = foo1_iface_impl<Base, Holder, T>;
};
struct foo_model {
void foo() const
{
std::cout << "foo_model calling foo()\n";
}
};
template <typename Base, typename Holder, typename T>
struct foo2_iface_impl : public Base {
void foo() const override
{
std::cout << "foo2_iface_impl calling foo()\n";
getval<Holder>(this).foo();
}
};
struct foo2_iface {
virtual void foo() const = 0;
template <typename Base, typename Holder, typename T>
using impl = foo2_iface_impl<Base, Holder, T>;
};
template <typename T>
concept fooable = requires(const T &x) { x.foo(); };
template <typename Base, typename Holder, typename T>
requires fooable<T>
struct foo3_iface_impl : public Base {
void foo() const override
{
std::cout << "foo3_iface_impl calling foo()\n";
getval<Holder>(this).foo();
}
};
struct foo3_iface {
virtual void foo() const = 0;
template <typename Base, typename Holder, typename T>
using impl = foo3_iface_impl<Base, Holder, T>;
};
template <typename Base, typename Holder, typename T>
struct foo4_iface_impl {
};
template <typename Base, typename Holder, typename T>
requires fooable<T>
struct foo4_iface_impl<Base, Holder, T> : public Base {
void foo() const override
{
std::cout << "foo4_iface_impl calling foo()\n";
getval<Holder>(this).foo();
}
};
template <typename Base, typename Holder>
struct foo4_iface_impl<Base, Holder, int> : public Base {
void foo() const override
{
std::cout << "foo4_iface_impl implementing foo() for the integer " << getval<Holder>(this) << "\n";
}
};
struct foo4_iface {
virtual void foo() const = 0;
template <typename Base, typename Holder, typename T>
using impl = foo4_iface_impl<Base, Holder, T>;
};
int main()
{
using foo1_wrap = tanuki::wrap<foo1_iface>;
foo1_wrap w1(foo_model{});
w1->foo();
using foo2_wrap = tanuki::wrap<foo2_iface>;
foo2_wrap w2(foo_model{});
w2->foo();
using foo3_wrap = tanuki::wrap<foo3_iface>;
foo3_wrap w3(foo_model{});
w3->foo();
std::cout << std::boolalpha;
std::cout << "Is foo3_wrap constructible from an int? " << std::is_constructible_v<foo3_wrap, int> << '\n';
using foo4_wrap = tanuki::wrap<foo4_iface>;
foo4_wrap w4(foo_model{});
w4->foo();
foo4_wrap w4a(42);
w4a->foo();
std::cout << "Is foo4_wrap constructible from a float? " << std::is_constructible_v<foo4_wrap, float> << '\n';
}