c++copy-constructorstd-function

C++: Best way to strengthen the type safety of assignment to std::function?


Summary: Earlier, I asked this question: Why does C++ allow std::function assignments to imply an object copy? Now that I understand why the code works the way it does, I would like to know how to fix it. If a parameter is const X&, I do not want functions that take just X to be assignable. Is there some sort of signature change or wrapper class around X or other technique that would guarantee that only functions that match a typedef exactly can be assigned to std::function of that typedef?

I know I could remove the copy constructor from the parameter class, but we need the copy constructor in other parts of the code. I am hoping to avoid a deeper refactoring of adding a formal "CopyMe" method that has to be added everywhere else. I want to fix the std::function assignment, if possible.

Details: The C++ type std::function does not enforce strict assignment with regard to "const" and "&" parameters. I can have a typedef whose parameter is const X& like this:

typedef std::function<void(const Thing&)> ConstByRefFunction;

but it allows assignment of a function that is by value like this:

void DoThingByValue(Thing event);
ConstByRefFunction func = DoThingByValue; // LEGAL!

For a full example with context, see the code below. The assignment injects a copy constructor to make the call.

I do not want that to be legal. I am trying to write C++ code that tightly controls when copy constructors get invoked and enforce across my application that all functions assigned to a particular callback follow the exact same signature, without allowing the deviation shown above. I have a couple of ideas that I may try to strengthen this (mostly involving wrapper classes for type Thing), but I am wondering if someone has already figured out how to force some type safety. Or maybe you've already proved out that it is impossible, and I just need to train devs and make sure we are doing strict inspection in pull requests. I hate that answer, but I'll take it if that's just the way C++ works.

Below is the full example. Note the line marked with "I WISH THIS WOULD BREAK IN COMPILE":

#include <memory>
#include <functional>
#include <iostream>

class Thing {
  public:
    Thing(int count) : count_(count) {}
    int count_;
};

typedef std::function<void(const Thing&)> ConstByRefFunction;

void DoThingByValue(Thing event) { event.count_ += 5; }

int main() {
  Thing thing(95);
  ConstByRefFunction func = DoThingByValue; // I WISH THIS WOULD BREAK IN COMPILE
  // The following line copies thing even though anyone looking at
  // the signature of ConstByRefFunction would expect it not to copy.
  func(thing);
  std::cout << thing.count_ << std::endl; // still 95
  return 0;
}

Solution

  • Instead of providing your own std::function-like implementation enforcing your requirements, you could have a slightly different approach by using a make_function helper that takes as template parameter the type you want to use (ConstByRefFunction in your example) and the function to be encapsulated. make_function will return the correct std::function object if the check is ok, otherwise a compilation error is generated. Example of use:

    struct Thing {  int count; };
    
    void DoThingByValue     (Thing        event) {}
    void DoThingByConstRef  (Thing const& event) {}
    
    using ConstByRefFunction = std::function<void(Thing const&)>;
    
    int main()
    {
        // Compilation error here ("f1 has incomplete type")
        // auto f1 = make_function<ConstByRefFunction>(DoThingByValue);
    
        // This one compiles and f2 is a std::function<void(Thing const&)> object
        auto f2 = make_function<ConstByRefFunction>(DoThingByConstRef);
        f2 (Thing {95});
    
        return 0;
    }
    

    The definition of make_function is

    template<typename T, typename FCT>
    [[nodiscard]]auto make_function (FCT fct)
    {
         if constexpr (signatures_match<T, decltype(std::function(fct))> () )  {
             return T(fct);
         }
         // if the two signatures differ, we return nothing on purpose.
    }
    

    which compares the two signatures with the signatures_match function. If the constexpr check fails, nothing is returned and then one gets a compilation error such as variable has incomplete type 'void' when trying to assign the result of make_function to an object.

    signatures_match can be implemented by some textbook type traits:

    template<typename T> struct function_traits;
    
    template<typename R, typename ...Args>
    struct function_traits<std::function<R(Args...)>> {
        using targs = std::tuple<Args...>;
    };
    
    template<typename F1, typename F2>
    constexpr bool signatures_match ()
    {
        // we get the signatures of the two provided std::function F1 and F2
        using sig1 = typename function_traits<F1>::targs;
        using sig2 = typename function_traits<F2>::targs;
    
        if (std::tuple_size_v<sig1> != std::tuple_size_v<sig2>)  { return false; }
    
        // we check that the two signatures have the same types.
        return [&] <auto...Is> (std::index_sequence<Is...>) {
            return std::conjunction_v <
                std::is_same <
                    std::tuple_element_t<Is,sig1>,
                    std::tuple_element_t<Is,sig2>
                >...
            >;
        } (std::make_index_sequence<std::tuple_size_v<sig1>> {});
    }
    

    Demo

    It compiles with c++20 but modifications are quite simple to make it compile with c++17.


    Update

    If you want to have a little bit more specific error message, it is also possible to simply rely on concepts:

    template<typename T, typename FCT>
    [[nodiscard]]auto make_function (FCT fct)
    requires (signatures_match<T,decltype(std::function(fct))>()) {
       return T(fct);
    }
    

    For instance, clang would produce the following error message:

    <source>:49:15: error: no matching function for call to 'make_function'
       49 |     auto f1 = make_function <ConstByRefFunction>(DoThingByValue);
          |               ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    <source>:40:19: note: candidate template ignored: constraints not satisfied [with T = ConstByRefFunction, FCT = void (*)(Thing)]
       40 | [[nodiscard]]auto make_function (FCT fct)
          |                   ^
    <source>:41:11: note: because 'signatures_match<std::function<void (const Thing &)>, decltype((std::function<void (Thing)>)(fct))>()' evaluated to false
       41 | requires (signatures_match<T,decltype(std::function(fct))>()) {
    

    Demo