DEV Community

Christian Daley
Christian Daley

Posted on

Virtual function templates with stateful metaprogramming in C++ 20: Part 2

This post can also be read on my github blog

In part 1 of this series we learned how to implement a virtual function template with a variadic parameter pack. In this post we're going to expand on our code to allow for an arbitrary number of virtual function templates with different return types. And we'll do it all with one single vtable!

Generalizing the vtable functions

Our current vtable_func implementation looks like this:

template <typename... Args>
struct vtable_func
{
    template <typename Derived>
    static void run(Printer* printer, void* argsTuplePtr)
    {
        const auto bound = [&](Args&&... args)
        {
            static_cast<Derived*>(printer)->print(std::forward<Args>(args)...);
        };

        auto& argsTuple = *static_cast<std::tuple<Args&&...>*>(argsTuplePtr);

        std::apply(bound, std::move(argsTuple));
    }
};
Enter fullscreen mode Exit fullscreen mode

Right now it's limited to calling the print function on the target object, but with some straightfoward changes we can make it able to call any member function. To start off we're going to rename the template parameter pack onvtable_func from Args to a more generic name Ts. The reason is because Ts is now going to represent the types of the template arguments for whatever function we're calling, and those are not necessarily the same thing as the types of the arguments to the function. For example:

template <typename... Ts>
void f(int i, double d, Ts&&... ts);

template <typename... Ts>
void g();
Enter fullscreen mode Exit fullscreen mode

Here we have two different functions with template parameter packs Ts. The function f takes arguments of type (int, double, Ts&&...) and the function g takes no arguments. We need to be able to handle virtual functions like these where the types of the template arguments don't necessarily correspond to the types of the function arguments.

The second thing we'll do is add a new run_impl function to vtable_func that will help us generalize run. We can even make this new function private as a matter of good practice. run will pass its arguments to run_impl, but will additionally pass a pointer-to-member-function that is intended to be called. The return type R and parameter types Args of the member function can be deduced by the compiler.

template <typename Derived, typename R, typename... Args>
static void run_impl(R(Derived::* func)(Args...), Printer* printer, void* argsTuplePtr)
{
    const auto bound = std::bind_front(func, static_cast<Derived*>(printer));

    auto& argsTuple = *static_cast<std::tuple<Args&&...>*>(argsTuplePtr);

    std::apply(bound, std::move(argsTuple));
}
Enter fullscreen mode Exit fullscreen mode

run_impl looks very similar to our original run function except for the extra R(Derived::* func)(Args...) parameter. This is a parameter named func that is a pointer to a member function of the Derived class. It takes arguments of type Args... and has return type R. The compiler is able to deduce Derived, Args and R based on whatever function we give to run_impl. For simplicity I've replaced the bound lambda with std::bind_front which does the same thing in only one line of code. Everything else is the same as before: we cast argsTuplePtr to the correct tuple type and then use std::apply to call bound with the arguments.

The run function itself becomes simple and our whole vtable_func struct looks like this:

template <typename... Ts>
struct vtable_func
{
private:
    template <typename Derived, typename R, typename... Args>
    static void run_impl(R(Derived::* func)(Args...), Printer* printer, void* argsTuplePtr)
    {
        const auto bound = std::bind_front(func, static_cast<Derived*>(printer));

        auto& argsTuple = *static_cast<std::tuple<Args&&...>*>(argsTuplePtr);

        std::apply(bound, std::move(argsTuple));
    }

public:
    template <typename Derived>
    static void run(Printer* printer, void* argsTuplePtr)
    {
        run_impl(&Derived::template print<Ts...>, printer, argsTuplePtr);
    }
};
Enter fullscreen mode Exit fullscreen mode

run passes a pointer to whatever member function we want to call (currently just Derived::print<Ts...>) along with the pointer to the Printer and the pointer to the arguments tuple. run_impl is then responsible for invoking that function on the printer.

Adding a new virtual function template

Now we're able to run any arbitrary member function we want by passing it to run_impl. We already have a print function that prints the arguments to std::cout. Let's add a new print_to_stream function that will let us print to any std::ostream. First we'll add the implementation to PrinterImpl.

template <typename... Args>
void print_to_stream(std::ostream& stream, Args&&... args)
{
    ((stream << args << '\n'), ...);
}
Enter fullscreen mode Exit fullscreen mode

Inside vtable_func::run we can invoke this new function just like we invoked print.

run_impl(&Derived::template print_to_stream<Ts...>, printer, argsTuplePtr);
Enter fullscreen mode Exit fullscreen mode

But wait a minute! How is the run function supposed to know what function to call? It currently doesn't have enough information to know whether it should run print or print_to_stream. How do we fix this? With another template parameter of course!

Let's define an enum:

enum class Function
{
    Print,
    PrintToStream,
};
Enter fullscreen mode Exit fullscreen mode

vtable_func will take a Function as a non-type template parameter in addition to the Ts parameter pack.

template <Function F, typename... Ts>
struct vtable_func
Enter fullscreen mode Exit fullscreen mode

run uses it to determine which member function pointer it needs to pass to run_impl.

template <typename Derived>
static void run(Printer* printer, void* argsTuplePtr)
{
    if constexpr (F == Function::Print)
    {
        run_impl(&Derived::template print<Ts...>, printer, argsTuplePtr);
    }
    else if constexpr (F == Function::PrintToStream)
    {
        run_impl(&Derived::template print_to_stream<Ts...>, printer, argsTuplePtr);
    }
}
Enter fullscreen mode Exit fullscreen mode

The final step is to use the correct Function case when pushing the vtable_func into the stateful_type_list. The original Printer::print function will now look like this:

template <typename... Args,
          size_t Index = stateful_type_list::try_push<vtable_func<Function::Print, Args...>>()>
void print(Args&&... args)
Enter fullscreen mode Exit fullscreen mode

And Printer::print_to_stream is implemented like so:

template <typename... Args,
          size_t Index = stateful_type_list::try_push<vtable_func<Function::PrintToStream, Args...>>()>
void print_to_stream(std::ostream& stream, Args&&... args)
{
    auto argsTuple = std::forward_as_tuple(stream, std::forward<Args>(args)...);

    m_vtable[Index](this, &argsTuple);
}
Enter fullscreen mode Exit fullscreen mode

Testing it out

Let's try out our new virtual function template! In main we'll call print_to_stream instead of print and see what happens.

auto p = make_printer();

double d = 2.5;
const std::string s = "Hello, world!";

p->print_to_stream(std::cerr, 5, d, s);
Enter fullscreen mode Exit fullscreen mode

The output prints to cerr like we expect.

5
2.5
Hello, world!
Enter fullscreen mode Exit fullscreen mode

Any number of virtual function templates can be supported by adding a new Function case and using it accodingly.

Supporting return values

We can have as many virtual function templates as we want, so let's figure out how to support returning values from these functions. We'll add a third function to our PrinterImpl class called print_to_string. We also need to add a corresponding PrintToString case to the Function enum.

template <typename... Args>
std::string print_to_string(Args&&... args)
{
    std::stringstream stream;

    ((stream << args << '\n'), ...);

    return stream.str();
}
Enter fullscreen mode Exit fullscreen mode

We want to return a std::string from Printer::print_to_string but this is complicated by the fact that we use a single vtable for all of our functions and, in general, each function might have a different return type. One solution could be to have vtable_func::run return a std::variant of all the possible return types (using std::monostate to represent void) and then use std::get to grab the correct type in our Printer implementation. This would work just fine but it is potentially inefficient. Let's say that we have 10 virtual function templates and nine of them return small types like int or char but one of them returns a std::array<int, 1000>. Because std::variant must have a size that is at least as big as its largest possible type, this will force all of our functions to have a large return type and lead to unnecessay copying of data.

The more efficient solution is to have the caller of the vtable function provide the storage for the return type and then pass a pointer to that storage into the vtable function. We'll add a void* ret parameter to vtable_func::run and pass that through to run_impl.

template <typename Derived>
static void run(Printer* printer, void* argsTuplePtr, void* ret)
{
    if constexpr (F == Function::Print)
    {
        run_impl(&Derived::template print<Ts...>, printer, argsTuplePtr, ret);
    }
    else if constexpr (F == Function::PrintToStream)
    {
        run_impl(&Derived::template print_to_stream<Ts...>, printer, argsTuplePtr, ret);
    }
    else if constexpr (F == Function::PrintToString)
    {
        run_impl(&Derived::template print_to_string<Ts...>, printer, argsTuplePtr, ret);
    }
}
Enter fullscreen mode Exit fullscreen mode

Inside run_impl we need to move the result of calling func into the storge provided by ret. We'll use a std::optional for the storage, so run_impl will cast ret from a void* to a std::optional<R>* and assign to it. We also need to have special logic for the case where the return type is void because we can't assign void to anything or create a std::optional<void>.

template <typename Derived, typename R, typename... Args>
static void run_impl(R(Derived::* func)(Args...), Printer* printer, void* argsTuplePtr, void* ret)
{
    const auto bound = std::bind_front(func, static_cast<Derived*>(printer));

    auto& argsTuple = *static_cast<std::tuple<Args&&...>*>(argsTuplePtr);

    if constexpr (std::is_same_v<R, void>)
    {
        std::apply(bound, std::move(argsTuple));
    }
    else
    {
        *static_cast<std::optional<R>*>(ret) = std::apply(bound, std::move(argsTuple));
    }
}
Enter fullscreen mode Exit fullscreen mode

Printer::print and Printer::print_to_stream will be updated to pass a nullptr as the ret argument to the vtable function. This is safe because both of these functions return void so vtable_func::run_impl won't attempt to use the pointer. Printer::print_to_string is implemented like so:

template <typename... Args,
          size_t Index = stateful_type_list::try_push<vtable_func<Function::PrintToString, Args...>>()>
std::string print_to_string(Args&&... args)
{
    auto argsTuple = std::forward_as_tuple(std::forward<Args>(args)...);

    std::optional<std::string> ret;

    m_vtable[Index](this, &argsTuple, &ret);

    return std::move(*ret);
}
Enter fullscreen mode Exit fullscreen mode

Let's verify that it works.

auto p = make_printer();

double d = 2.5;
const std::string s = "Hello, world!";

const auto str = p->print_to_string(5, d, s);

std::cout << str;
Enter fullscreen mode Exit fullscreen mode

The output is what we expect.

5
2.5
Hello, world!
Enter fullscreen mode Exit fullscreen mode

Play around with the code here: https://godbolt.org/z/Gsr1EKavz

View it on my github: https://github.com/christiandaley/examples/blob/main/cpp/virtual_function_templates-part-2/main.cpp

Final remarks

We've seen that supporting an arbitrary number of virtual function templates and different return types is quite straightforward. The amount of new code we added was pretty small and the only new type introduced was the Function enum.

It should be noted that our existing implementation does not support returning reference types because std::optional cannot contain a reference. This limitation is easily overcome by having an additional if constexpr case in run_impl and using a raw pointer instead of a std::optional to temporarily store the return value. The implementation of this is left as an exercise for the reader.

What's next?

At the end of part one I mentioned that these virtual function templates are unsafe to use across translation units because of ODR violations that result from stateful metaprogramming. I also teased that it may be possible to get around this limitation, and in part 3 that's exactly what we'll do.

Top comments (0)