3

I have a value which is of type std::variant<char, int, double>. In fact, I have several values stored in a std::vector<value>.

I want to be able to pass these values to several callback functions, if their signature matches the types of values. Here is my implementation:

using value = std::variant<char, int, double>;
using values = std::vector<value>;

template<typename... Args>
using callable = std::function<void (Args...)>;

struct callback
{
    template<typename... Args>
    callback(callable<Args...> fn)
    {
        constexpr auto N = sizeof...(Args);
        fn_ = adapt(std::move(fn), std::make_index_sequence<N>());
    }
    
    void operator()(const values& vv) const { fn_(vv); }
    
private:
    std::function<void (const values&)> fn_;

    template<typename... Args, std::size_t... Is>
    auto adapt(callable<Args...> fn, std::index_sequence<Is...>)
    {
        return [fn_ = std::move(fn)](const values& vv) {
            /* if signature matches */ fn_(std::get<Args>(vv[Is])...);
        };
    }
};

using callbacks = std::vector<callback>;

Now I can do this and it works mostly well:

void fn(char c, int i) { std::cout << "fn:" << c << i << '\n'; }

int main()
{
    values vv = { 'x', 42 };
    
    callback cb1{ std::function<void (char, int)>(fn) };
    callback cb2{ std::function<void (char, int)>([](char c, int i) { std::cout << "lambda:" << c << i << '\n'; }) };

    callbacks cbs = { cb1, cb2 };
    for(auto const& cb : cbs) cb(vv);

    return 0;
}

You can see working example here.

I've reviewed several similar questions about (im)possibility of implicitly converting function pointers, lambdas, functors, etc. to std::function.

So my question is, what "glue" do I need into order to be able to do this:

callback cb3 { fn };
callback cb4 { [](char c, int i) { } };
// same for other callable types

I thought of some sort of function_traits implementation to extract signature from a callable type and pass it to std::function. But looking at the code of std::is_function makes me think this will require a lot of boilerplate code.

I'd like to ask here if anyone can think of a more concise solution. Thank you in advance.


UPDATE

I was able to accomplish this using sweat, blood and tears partial specialization, SFINAE and void_t trick. I am now able to do this:

callback cb1 { fn };
callback cb2 { lambda };
callback cb3 { [](char c, int i) { std::cout << "lambda2:" << c << i << '\n'; } };

int x = 69;
callback cb4 { [x](char c, int i) { std::cout << "lambda3[x]:" << x << c << i << '\n'; } };
callback cb5 { ftor(x) };

Here is the demo. In this implementation callback can accept function pointers, lambdas (including capturing, but not generic) and functors.

2 Answers2

3

As d_kog commented, the question itself is rarely the right one in C++. A callable need not have a unique signature. Therefore, you should re-frame the problem. Instead of trying to determine the signature to check for call-ability, check for call-ability directly.

Your callback has to do three fundamental things with a vector of values:

  1. "Lift" the size to compile-time, e.g. turn the vector into a tuple or a pack.
  2. visit the values, to get the value inside the variants.
  3. apply the function to them, if possible.

Steps 2 and 3 are straightforward. The "find-the-signature" is being used to find the arity so that an index sequence and vv[Is]... gives you a pack (assuming vv's size() is checked first). Then you use get<Args> essentially to implement visit, but that is unnecessary.

But there are other options for step 1. For example, if you only support up to a certain number of arguments, this is effective:

template<typename F>
void call_helper(const values& vv, F&& f)
{
    switch (vv.size()) {
    case 0: std::forward<F>(f)(); break;
    case 1: std::forward<F>(f)(vv[0]); break;
    case 2: std::forward<F>(f)(vv[0], vv[1]); break;
    case 3: std::forward<F>(f)(vv[0], vv[1], vv[2]); break;
    default: throw std::out_of_range{"too many arguments to callback"};
    }
}

Then adapt becomes:

return [fn_ = std::move(fn)](const values& vv) {
    // lift from the vector to function arguments
    call_helper(vv, [&](const auto&... args) {
        // visit the arguments
        std::visit([&](const auto&... args_unwrapped) {
            // check invocability with the arguments
            if constexpr (std::is_invocable_v<decltype(fn_), decltype(args_unwrapped)...>)
                // invoke
                std::invoke(fn_, args_unwrapped...);
            else
                throw std::runtime_error{"invalid argument types to callback"};
        }, args...);
    });
};

Then, you can support all manner of C++ callables:

callback cb1{ fn }; // function pointer
callback cb2{ [](char c, int i) { std::cout << "lambda:" << c << i << '\n'; } };
callback cb3{ [](auto c, auto i) { std::cout << "generic lambda:" << c << i << '\n'; }};
int j = 1000;
callback cb4{ [j](char c, int i) { std::cout << "capturing lambda[" << j << "]:" << c << i << '\n'; }};
callback cb5{ [](int c, int i) { std::cout << "implicit conversion allowed:" << c << i << '\n'; }};
callback cb6{ [] { /* "invalid argument types to callback" */ }};

Demo

Jeff Garrett
  • 5,863
  • 1
  • 13
  • 12
2

At least for your described use cases, what you want can be achieved by adding additional callback constructor templates.

template<typename... Args>
callback(void (*fn)(Args...)) : callback(callable<Args...>(fn)) {}   

This constructor will match an argument that can decay to a function pointer, e.g. this constructor call now works

callback cb3{ fn }; // ok

Note that this new constructor simply delegates to your original constructor by explicitly casting the function pointer to a callback<Args...>.


Similarly, you can write a template that will match an argument that is a lambda.

template<typename Lambda>
callback(Lambda fn) : callback(+fn) {}

and now this constructor call works.

callback cb4{ [](char c, int i) { std::cout << "lambda:" << c << i << '\n'; } }; // ok

Note here that the delegation is to the constructor taking a function pointer. This is achieved by simply explicitly decaying the lambda to a function pointer with the unary + operator. Of course, that constructor will further delegate to your original constructor with the actual implementation.


Here's a demo.

cigien
  • 57,834
  • 11
  • 73
  • 112
  • why the `+` in the lamba ctor? – Super-intelligent Shade Dec 29 '20 at 22:03
  • 1
    @InnocentBystander As mentioned, `+` will cause a lambda to decay to a function pointer, which allows you to delegate to the constructor taking a function pointer. There are other ways of decaying the lambda as well, but `+` is a fairly idiomatic way of doing it. – cigien Dec 29 '20 at 22:10
  • ah sorry missed the last paragraph. :) What would you recommend for the rest of the callable types? I.e. class member function, functor and `std::bind()` (not sure if I've missed anything else). – Super-intelligent Shade Dec 29 '20 at 23:45
  • @InnocentBystander It depends on each one. You might need to do the cast explicitly at the call site for some cases, e.g. if the lambda that you pass captures anything. – cigien Dec 29 '20 at 23:53
  • cigien, if it's not too much to ask, would you mind adding overloads for capturing lambda and bind expressions? I can't wrap my head around it... – Super-intelligent Shade Dec 30 '20 at 00:08
  • @InnocentBystander No, that's not what I meant actually. There may not be constructor overloads that will handle capturing lambdas at all, and so you'll have to be explicit at the call site, like you did for `cb1`, and `cb2`. – cigien Dec 30 '20 at 00:12
  • cigien, How does `std::function` do it then? Some sort of compiler assisted trick? – Super-intelligent Shade Dec 30 '20 at 01:01
  • @InnocentBystander Actually, I don't think `std::function` does anything that can't be implemented in user code. There are certainly limits on what `std::function` will deduce if the template arguments are not specified. – cigien Dec 30 '20 at 01:38
  • "How does std::function do it": std::function only supports deduction from function pointers and non-generic function objects (i.e. has unique operator()). The constructor is constrained to take only things invocable like the signature, but that presupposes the signature except in those cases. – Jeff Garrett Dec 30 '20 at 16:03