Is there a way to avoid storage overhead when using a function as a callback?

Viewed 697

Given the following setup:

// ***** Library Code *****
#include <concepts>

template <std::invocable CbT>
struct delegated {
  explicit constexpr delegated(CbT cb) : cb_(std::move(cb)) {}

 private:
  [[no_unique_address]] CbT cb_;
};

// ***** User Code *****
#include <iostream>

namespace {
  inline constexpr void func() {}
}

struct MyFunc {
  constexpr void operator()() const {}
};


int main() {
    void (*func_ptr)() = func;

    auto from_func = delegated{func};
    auto from_func_ptr = delegated{func_ptr};
    auto from_lambda = delegated{[](){}};
    auto from_functor = delegated{MyFunc{}};

    std::cout << "func: " << sizeof(from_func) << "\n";
    std::cout << "func_ptr: " << sizeof(from_func_ptr) << "\n";
    std::cout << "lambda: " << sizeof(from_lambda) << "\n";
    std::cout << "functor: " << sizeof(from_functor) << "\n";
}

It produces, on GCC-x86-64 (See on godbolt):

func: 8        <----- Unfortunate
func_ptr: 8    <----- Fair enough
lambda: 1      <----- Neat
functor: 1     <----- Neat

None of this is particularly surprising.

However, it's frustrating that an undecayed lambda is preferable to using a function. And adding a note that delegated{[]{func();}} reduces the storage overhead is not exactly user-friendly, and makes for a very poor library interface.

Is there a way to do away with the storage overhead in the func case while maintaining a consistent user-facing API?

My current suspicion is that this is not possible without resorting to macros, on account of func not having, or decaying into, any type that would distinguish it from other functions with the same signature. I'm hoping that I overlooked something.

N.B. I get that something along the lines of delegated<func>() is a possibility, but unless I can prevent delegated{func} while still allowing delegated{func_ptr}, then that would be practically pointless.

Edit: To clarify the context a little bit: I am writing delegated in a library, and I don't want users of said library to have to worry about this. Or at least have the process be compiler-assisted instead of being documentation-dependant.

3 Answers

There are no objects of function types. The type will be adjusted to be a function pointer, which is why you delegated{func} and delegated{func_ptr} are exactly the same thing and former cannot be smaller.

Wrap the function call inside a function object (lambda, if you so prefer) to avoid the overhead of the function pointer.


If you would like to prevent the accidental use of the adjusted/decayed function pointer case when user tries to pass a function, then you could use a deleted overload for function references. I don't know how that could be achieved with CTAD, but if you provide a function interface, it could be done like this:

constexpr auto
make_delegated(std::invocable auto CbT)
{
    return delegated{std::move(CbT)};
}

template<class... Args>
constexpr auto
make_delegated(auto (&cb)(Args...)) = delete;

Edit: Combining ideas with Human-Compiler's answer

template <auto CbT>
constexpr auto
make_delegated_fun() { 
  return delegated{ []{ CbT(); } };
}

constexpr auto
make_delegated(std::invocable auto CbT)
{
    return delegated{std::move(CbT)};
}

template<class... Args>
constexpr auto
make_delegated(auto (&cb)(Args...)) {
    // condition has to depend on template argument;
    // just false would cause the assert to trigger without overload being called.
    static_assert(!std::is_reference_v<decltype(cb)>, "please use make_delegated_fun");
};


auto from_func1 = make_delegated(func);        // fails to compile
auto from_func2 = make_delegated_fun<func>();  // OK
auto from_func_ptr = make_delegated(func_ptr); // OK, pointer overhead
auto from_lambda = make_delegated([](){});     // OK
auto from_functor = make_delegated(MyFunc{});  // OK

Caveat, this would prevent following, and the example wouldn't work using make_delegated_fun either so the message would be misleading. The example could easily be rewritten to use function pointers or capturing lambda though:

auto& fun_ref = condition ? fun1 : fun2;
make_delegated(fun_ref);       // fails to compile, suggests make_delegated_fun
make_delegated_fun<fun_ref>(); // fails to compile, not constexpr
make_delegated(&fun_ref);      // OK, pointer overhead

Passing the function pointer as a template argument does not require any space at runtime. For example:

template <auto F>
struct delegated_erased {
    template <typename... argument_t>
    auto operator()(argument_t&&... argument){
        F(std::forward<argument_t>(argument)...);
    }
};
auto from_func = delegated_erased<func>{};
std::cout << "func: " << sizeof(from_func) << "\n"; // 1

With a helper function, you can combine this with your code:

template <typename result, typename ... argument>
delegated<result> make_delegated(result(&)(argument...)) = delete;

template <typename T>
delegated<T> make_delegated(T f) {
    return delegated{std::move(f)};
};

template <auto F>
delegated_erased<F> make_delegated(){
    return {};
}

Which allows you to do:

    auto from_func = make_delegated<func>();
    //auto from_func = make_delegated(func); // error: call to deleted function 'make_delegated'
    auto from_func_ptr = make_delegated(func_ptr);
    auto from_lambda = make_delegated([](){});
    auto from_functor = make_delegated(MyFunc{});

The only way to really remove the "storage" from a function like this is to use the value at compile-time. The only real way to accomplish this is through a non-type template argument.

A factory function could do this easily with little changes, and keeps the implementation simple. You just need to accept the callable object as a template non-type argument -- such as an auto parameter, so that its known at compile-time without any storage requirements.

One way to perform this is to just use your lambda-wrapping solution with the existing code:

template <auto Fn>
auto make_delegated() { 
  return delegated{ []{ Fn(); } };
}

Then the following works:

auto from_func = make_delegated<&func>();
std::cout << "func: " << sizeof(from_func) << "\n";

this yields the correct value:

func: 1

Live Example


As an alternative measure, you could also require the user to wrap the function itself in a sentinel type that carries the data:

template <auto Fn>
struct empty_func{
  auto operator()() { return Fn(); }
};

This would be almost equivalent to using a lambda, though the user instead only has to do:

auto from_func = delegated{empty_func<&func>{}};

The point is just that the function needs to be carried at compile-time somewhere.

Related