Search code examples
c++metaprogramming

Template metaprogramming solution for calling a function with arbitrary number of arguments in O(1)


I have a table of functions, all of them returning a double but with an arbitrary number of doubles as arguments. For example:

double sum1(double a, double b, double c) {
    return a + b + c;
}
double sum2(double a, double b, double c, double d) {
    return a + b + c + d;
}
double sum3(double a, double b) {
    return a + b;
}

And I have a table that summarizes and provides some metadata for these functions

struct Function {
    void* fnptr;
    size_t numargs;
};
Function fntable[] = {{(void*)&sum1, 3}, {(void*)&sum2, 4}, {(void*)&sum3, 2}};

What I want to do is to be able to call say sum3 with just its index and a double* array, and have the solution figure out to make the proper call, ie place the doubles in appropriate registers for the call.

The classic solution for this would be using a manual switch case and for each number of arguments have the call explicitly up to a maximum number say 20, perhaps with the help with macros to alleviate the typing, as shown below.

template <size_t N, typename... Args>
struct FnType {
    using Call = typename FnType<N - 1, double, Args...>::Call;
};

template <typename... Args>
struct FnType<0, Args...> {
    typedef double (*Call)(Args...);
};

double callfn(void* fn, const std::vector<double>& args) {
    switch ( args.size() ) {
        case 0: { return FnType<0>::Call(fn)(); }; break;
        case 1: { return FnType<1>::Call(fn)(args[0]); }; break;
        case 2: { return FnType<2>::Call(fn)(args[0],args[1]); }; break;
        case 3: { return FnType<3>::Call(fn)(args[0],args[1],args[2]); }; break;
        case 4: { return FnType<4>::Call(fn)(args[0],args[1],args[2],args[3]); }; break;
    };
    return std::numeric_limits<double>::quiet_NaN();
}

This works but I have a requirement to be able to change the maximum number of arguments arbitrarily without changing the code.

This is the driver for this feature. Everything is available on Godbolt: https://godbolt.org/z/9xGaTG491

int main(int argc, char* argv[]) {
    if (argc == 1) {
        std::cout << "Usage: caller <fnum> [<value>...]" << std::endl;
        return 0;
    }
    int fnum = std::atoi(argv[1]);
    int numargs = argc - 2;
    std::vector<double> args(numargs);
    for (int j = 0; j < numargs; ++j) args[j] = std::atof(argv[j + 2]);
    Function fn = fntable[fnum];
    if (fn.numargs != numargs) {
        std::cout << "Wrong number of arguments for function #" << fnum << ". Got "
                  << numargs << " expected " << fn.numargs << std::endl;
        return 1;
    }
    std::cout << callfn(fn.fnptr, args) << std::endl;
}

And this is a typical session

$ ./caller 1
Wrong number of arguments for function #1. Got 0 expected 4
$ ./caller 1 1 2 3 4
10
$ ./caller 1 10 20 30 40
100
$ ./caller 0 100 200 300 400
Wrong number of arguments for function #0. Got 4 expected 3
$ ./caller 0 100 200 300 
600
$ ./caller 2 4 5
9

There are a few things I am not checking as maximum function number etc but this is just a toy example.

So the question is: how to replace that switch statement with something O(1) where I can set arbitrarily the maximum number of parameters at compile time? Does metatemplate programming offer a solution?

Obviously I am not asking anyone to write my code but I would appreciate ideas.


Solution

  • You can use type erasure to handle any size of arguments.

    class Function
    {
        struct BaseCaller
        {
            virtual ~BaseCaller() = default;
            virtual size_t numargs() const = 0;
            virtual double call(std::vector<double>) const = 0;
        };
        
        template <typename... Args>
        requires (std::is_convertible_v<Args, double> && ...)
        class Caller : public BaseCaller
        {
            double(*func)(Args...);
        public:
            explicit Caller(double(*func)(Args...)) : func(func) {}
        
            size_t numargs() const override
            {
                return sizeof...(Args);
            }
            
            double call(std::vector<double> args) const override
            {
                return [&]<size_t... Is>(std::index_sequence<Is...>)
                {
                    return func(args[Is]...);
                }(std::index_sequence_for<Args...>{});
            }
        };
        
        std::unique_ptr<BaseCaller> caller;
        
    public:
        
        template <typename... Args>
        requires (std::is_convertible_v<Args, double> && ...)
        Function(double(*func)(Args...)) : caller(std::make_unique<Caller<Args...>>(func)) {}
        
        size_t numargs() const
        {
            return caller->numargs();
        }
    
        double operator()(std::vector<double> args) const
        {
            return caller->call(std::move(args));
        }
    };
    

    See it on coliru

    You can construct them at runtime thusly:

    constexpr size_t MaxArgs = 100;
    
    template <size_t>
    using double_t = double;
    
    template <size_t... Is>
    using RawFunction = double(*)(double_t<Is>...);
    
    Function fromVoid(void * f, size_t numargs)
    {
        static auto cast = []<size_t... Js>(std::index_sequence<Js...>, void * f){ return Function(reinterpret_cast<RawFunction<Js...>>(f)); };
        static auto array = [=]<size_t... Is>(std::index_sequence<Is...>) -> std::array<std::function<Function(void*)>, MaxArgs>
        {
            return { [=](void * f){ return cast(std::make_index_sequence<Is>{}, f); }... };
        }(std::make_index_sequence<MaxArgs>{});
        return array[numargs](f);
    }
    

    Also on coliru