This page looks best with JavaScript enabled

[EMCpp]Item-26 Avoid Overloading on Universal References

 ·  ☕ 4 min read · 👀... views

Overloading on universal references almost always leads to the universal reference overload being called more frequently than expected.

Why universal references?

We want to introduce universal references because we can eliminate some inefficiencies via it. For example,

1
2
3
4
5
6
7
std::multiset<std::string> names;
void logAndAdd(const std::string& name)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(name);  // add name to global data structure. See EMCpp Item 42 for info on emplace
}
1
2
3
4
std::string petName("Amy");
logAndAdd(petName);  // passing lvalue
logAndAdd(std::string("Ben"));  // passing rvalue
logAndAdd("Candice");  // passing string literal
  1. petName is lvalue, so name is bound to it, and then copied into names. Since an lvalue was passed into logAndAdd, the final copy operation can not be avoided.
  2. the parameter for the second call is rvalue, where a temporary of type std::string is explicitly created and gets bound to name, and then name get copied into names. In this call, we might optimize that final copy operation with a move operation since we are dealing with an rvalue
  3. similar procedure as the second call but the std::string is implicitly created from string literal Candice. We might optimize the final copy operation by creating the std::string object directly inside the std::multiset if emplace could use the string literal directly as argument, so there’s not even a move operation.

To achieve such optimization, we find universal reference, accompany with std::forward:

1
2
3
4
5
6
7
template<typename T>
void logAndAdd(T&& name)
{
    auto now = std::chrono::system_clock::now();  
    log(now, "logAndAdd");  
    names.emplace(std::forward<T>(name));
}

What trouble universal references introduces?

Functions taking universal references are the greediest functions in C++, so the usually overload more argument types than the developer generally expects. Suppose there’s another function overloading for the type of int:

1
2
3
4
5
6
7
std::string nameFromIdx(int idx); // return name corresponding to idx
void logAndAdd(int idx)
{
    auto now = std::chrono::system_clock::now();  
    log(now, "logAndAdd");  
    names.emplace(nameFromIdx(idx));
}
1
2
3
4
5
logAndAdd(22);  // calls int overload
logAndAdd("David");  // calls T&& overload
short nameIdx;
...
logAndAdd(nameIdx);  // error!

We generally assume the last call will invoke the int overload, but instead it will invoke the T&& one, since the universal reference overload version exactly matches the short argument by deduce T to be short&, while int version has to match short with a promotion. As a result, the exact match beats a match with a promotion. However, within the T&& overload, the parameter name with type short& first get passed into std::forward, which will not get casted into a rvalue since name is initialized with an lvalue (refer to EMCpp item 24), and then get passed into emplace member function on names, which finally forwards it to the std::string constructor, and we get an error here because no constructor for std::string will take a short.

More problematic: Perfect-forwarding consturctors

Perfect-forwarding constructors are typically better matches than copy constructors for non-const lvalues, and they can hijack derived class calls to base class copy and move constructors, which makes them problematic. For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Person {
public:
    template<typename T>
    explicit Person(T&& n)
    : name(std::forward<T>(n)) {}  // perfect forwarding ctor;
    
    explicit Person(int idx)
    : name(nameFromIdx(idx)) {} // int ctor
    ...
private:
    std::string name;
}

Remember in EMCpp item 17 we mensioned that, under appropriate conditions, the compiler will generate copy and move constructors for us, even if the class contains a templatized constructor that could be instantiated to produce the signature of the copy or move constructor. In that case, we get following two compiler-generated member functions:

1
2
Person(const Person& rhs);  // copy ctor generated by compiler
Person(Person&& rhs); // move ctor generated by compiler

And now comes the problem: these two member functions will easily get shadowed by the universal reference constructor:

1
2
3
4
5
Person p1("Edward");
auto cloneOfP1(p1);  // create new Person from p; 
                     // T&& ctor get invoked: T is deduces as "Person&", better than copy ctor's type "const Person&"
const Person p2("Fernando");
auto cloneOfP2(p2);  // fine; copy ctor get invoked.

Moreover, the inheritance makes the mess even worse:

1
2
3
4
5
6
7
8
9
class SpecialPerson: public Person {
public:
    SpecialPerson(const SpecialPerson& rhs)
    : Person(rhs)  // calls base class forwarding ctor
    { ... }
    SpecialPerson(SpecialPerson&& rhs)
    : Person(std::move(rhs)) // calls base class forwarding ctor
    { ... }
}

Here, the derived class will call the perfect forwarding constructor for their copy and move constructors, because they are using arguments of type SpecialPerson to pass to their base class, and base class’s forwarding constructor will happily instantiate an exact match for this call. Since there’s no std::string constructor taking a SpecialPerson, the code won’t compile.

Conclusion

Overloading on universal reference is something we should avoid if possible. However, if we do want a function that forwards most argument types, while still support special treatment for some special types, we can find some alternatives to achieve this goal in EMCpp item 27.

Share on
Support the author with