Home > Software design >  C 11 - Why compiler does not optimize rvalue reference to const lvalue reference binding?
C 11 - Why compiler does not optimize rvalue reference to const lvalue reference binding?

Time:11-13

In the next test code, we have a simple class MyClass with only one variable member (int myValue) and one function (MyClass getChild()) that returns a new instance of MyClass. This class has the main operators overloaded to print when they are called.

We have three functions with two parameters that perform a simple assignment (first_param = second_param): :

  • func1: the second parameter is an rvalue reference (also uses a std::forward)
void func1(MyClass &el, MyClass &&c) {
    el = std::forward<MyClass>(c);
}
  • func2: the second parameter is a const lvalue reference
void func2(MyClass &el, const MyClass &c) {
    el = c;
}
  • func3: two overloads (one equivalent to func1 and another equivalent to func2)
void func3(MyClass &el, MyClass &&c) {
    el = std::forward<MyClass>(c);
}
void func3(MyClass &el, const MyClass &c) {
    el = c;
}

In the main() function we call these three functions three times each, one passing an rvalue, another passing an lvalue, and another passing std::move(lvalue) (or what is the same, an rvalue reference). Prior to calling these functions, we also do a direct assignment (without calling any function) for an lvalue, rvalue, and rvalue reference.

The test code:

#include <iostream>
#include <utility>

class MyClass {
public:
    int myValue;

    MyClass(int n) {   // custom constructor
        std::cout << "MyClass(int n) [custom constructor]" << std::endl;
    }

    MyClass() {   // default constructor
        std::cout << "MyClass() [default constructor]" << std::endl;
    }

    ~MyClass() {  // destructor
        std::cout << "~MyClass() [destructor]" << std::endl;
    }

    MyClass(const MyClass& other) // copy constructor
    : myValue(other.myValue)
    {
        std::cout << "MyClass(const MyClass& other) [copy constructor]" << std::endl;
    }

    MyClass(MyClass&& other) noexcept // move constructor
    : myValue(other.myValue)
    {
        std::cout << "MyClass(MyClass&& other) [move constructor]" << std::endl;
    }

    MyClass& operator=(const MyClass& other) { // copy assignment
        myValue = other.myValue;
        std::cout << "MyClass& operator=(const MyClass& other) [copy assignment]" << std::endl;
        return *this;
    }

    MyClass& operator=(MyClass&& other) noexcept { // move assignment
        myValue = other.myValue;
        std::cout << "MyClass& operator=(MyClass&& other) [move assignment]" << std::endl;
        return *this;
    }

    MyClass getChild() const {
        return MyClass(myValue 1);
    }
};

void func1(MyClass &el, MyClass &&c) {
    el = std::forward<MyClass>(c);
}

void func2(MyClass &el, const MyClass &c) {
    el = c;
}

void func3(MyClass &el, MyClass &&c) {
    el = std::forward<MyClass>(c);
}
void func3(MyClass &el, const MyClass &c) {
    el = c;
}

int main(int argc, char** argv) {
    MyClass root(200);
    MyClass ch = root.getChild();
    MyClass result;

    std::cout << "==================================================================" << std::endl;
    std::cout << "------------- simple assignment to rvalue ------------------------" << std::endl;
    result = root.getChild();
    std::cout << "------------- simple assignment to lvalue ------------------------" << std::endl;
    result = ch;
    std::cout << "------------- simple assignment to std::move(lvalue) -------------" << std::endl;
    result = std::move(ch);
    std::cout << "==================================================================" << std::endl;
    std::cout << "------------- func1 with rvalue ----------------------------------" << std::endl;
    func1(result, root.getChild());
    std::cout << "------------- func1 with lvalue ----------------------------------" << std::endl;
    //func1(result, ch);  // does not compile
        std::cout << "** Compiler error **" << std::endl;
    std::cout << "------------- func1 with std::move(lvalue) -----------------------" << std::endl;
    func1(result, std::move(ch));
    std::cout << "==================================================================" << std::endl;
    std::cout << "------------- func2 with rvalue ----------------------------------" << std::endl;
    func2(result, root.getChild());
    std::cout << "------------- func2 with lvalue ----------------------------------" << std::endl;
    func2(result, ch);
    std::cout << "------------- func2 with std::move(lvalue) -----------------------" << std::endl;
    func2(result, std::move(ch));
    std::cout << "==================================================================" << std::endl;
    std::cout << "------------- func3 with rvalue ----------------------------------" << std::endl;
    func3(result, root.getChild());
    std::cout << "------------- func3 with lvalue ----------------------------------" << std::endl;
    func3(result, ch);
    std::cout << "------------- func3 with std::move(lvalue) -----------------------" << std::endl;
    func3(result, std::move(ch));
    std::cout << "==================================================================" << std::endl;

    return 0;
}

After compiling it with g (it doesn't matter if with -O0 or -O3) and running it the result is:

MyClass(int n) [custom constructor]
MyClass(int n) [custom constructor]
MyClass() [default constructor]
==================================================================
------------- simple assignment to rvalue ------------------------
MyClass(int n) [custom constructor]
MyClass& operator=(MyClass&& other) [move assignment]
~MyClass() [destructor]
------------- simple assignment to lvalue ------------------------
MyClass& operator=(const MyClass& other) [copy assignment]
------------- simple assignment to std::move(lvalue) -------------
MyClass& operator=(MyClass&& other) [move assignment]
==================================================================
------------- func1 with rvalue ----------------------------------
MyClass(int n) [custom constructor]
MyClass& operator=(MyClass&& other) [move assignment]
~MyClass() [destructor]
------------- func1 with lvalue ----------------------------------
** Compiler error **
------------- func1 with std::move(lvalue) -----------------------
MyClass& operator=(MyClass&& other) [move assignment]
==================================================================
------------- func2 with rvalue ----------------------------------
MyClass(int n) [custom constructor]
MyClass& operator=(const MyClass& other) [copy assignment]
~MyClass() [destructor]
------------- func2 with lvalue ----------------------------------
MyClass& operator=(const MyClass& other) [copy assignment]
------------- func2 with std::move(lvalue) -----------------------
MyClass& operator=(const MyClass& other) [copy assignment]
==================================================================
------------- func3 with rvalue ----------------------------------
MyClass(int n) [custom constructor]
MyClass& operator=(MyClass&& other) [move assignment]
~MyClass() [destructor]
------------- func3 with lvalue ----------------------------------
MyClass& operator=(const MyClass& other) [copy assignment]
------------- func3 with std::move(lvalue) -----------------------
MyClass& operator=(MyClass&& other) [move assignment]
==================================================================
~MyClass() [destructor]
~MyClass() [destructor]
~MyClass() [destructor]

For the assignment, the result is as expected. If you pass an rvalue, it calls to move assignment, if you pass an lvalue, it calls to copy assignment, and if you pass an rvalue reference (std::move(lvalue)) it calls to a move assignment.

The calls to func1 are also the expected (remember that this function receives an rvalue reference). If you pass an rvalue, it calls to move assignment, if you pass an lvalue, the compilation fails (because an lvalue can't bind to rvalue reference), and if you pass an rvalue reference (std::move(lvalue)) it calls to a move assignment.

But for func2, in the three cases, the copy assignment is called. This function receives as a second parameter a const lvalue reference, this is an lvalue and then it calls to copy assignment. I understand this, but, why the compiler does not optimize this function when it is called with a temporal object (rvalue or rvalue reference) calling the move assignment operator instead of the copy assignment?

The func3 is an attempt to create a function that works in the same way as a direct assignment, combining the func1 behaviour and defining an overload with func2 behaviour for when an lvalue is passed. This works, but this solution requires the function code to be duplicated into the two functions (not exactly, since in one solution we have to use std::forward). Is there a way to achieve this by avoiding having to duplicate the code? This function is small but could be larger in other contexts.

In summary, there are two questions:

Why is the func2 function not optimized to call the move assignment when it receives an rvalue or an rvalue reference?

How could I modify the func3 function so as not to have to "duplicate" the code?


EDIT to clarify my reflections after Brian's answer.

I understand the first point (on why the compiler does not optimize this). It is simply how it works by definition of the language, and the compiler cannot optimize this simply because which operators must be called on each occasion are well defined and must be respected. The programmer expects certain operators to be called and an optimization attempt would unpredictably change which and how they would be called. The only exceptions I have come across are Return Value Optimization (RVO), where the compiler can eliminate the temporary object created to hold the return value of a function; and the cases where Copy Elision can be applied to eliminate unnecessary copying of objects. According to its wikipedia article, the optimization can not be applied to a temporary object that has been bound to a reference (I think this is exactly what applies to our case):

Another widely implemented optimization, described in the C standard, is when a temporary object of class type is copied to an object of the same type. As a result, copy-initialization is usually equivalent to direct-initialization in terms of performance, but not in semantics; copy-initialization still requires an accessible copy constructor. The optimization can not be applied to a temporary object that has been bound to a reference.

On avoiding duplicate code, I have tried the solutions in the suggested SO posts ([1], [2]), which may be convenient on some occasions but they do not serve as an exact replacement for the solution with the duplicated code (func3), since they will work fine when an rvalue or rvalue reference is passed to the function, but they do not work exactly as expected when passing an lvalue.

To test this, taking into account the original code, we add two functions func4 and func5 to implement the proposed solutions:

template<typename T>
inline constexpr void func4(T &el, T &&c) {
    el = std::forward<T>(c);
}
template<typename T>
inline constexpr void func4(T &el, const T &c) {
    T copy = c;
    func4(el, std::move(copy));
}
template<class T>
std::decay_t<T> copy(T&& t) {
  return std::forward<T>(t);
}
template<typename T>
inline constexpr void func5(T &el, T &&c) {
    el = std::forward<T>(c);
}
template<typename T>
inline constexpr void func5(T &el, const T &c) {
    func5(el, copy(c));
}

As with the original functions, we call these functions with an rvalue, an lvalue and an rvalue reference (std::move(lvalue)), the result is the following:

==================================================================
------------- func4 with rvalue ----------------------------------
MyClass(int n) [custom constructor]
MyClass& operator=(MyClass&& other) [move assignment]
~MyClass() [destructor]
------------- func4 with lvalue ----------------------------------
MyClass(const MyClass& other) [copy constructor]
MyClass& operator=(MyClass&& other) [move assignment]
~MyClass() [destructor]
------------- func4 with std::move(lvalue) -----------------------
MyClass& operator=(MyClass&& other) [move assignment]
==================================================================
------------- func5 with rvalue ----------------------------------
MyClass(int n) [custom constructor]
MyClass& operator=(MyClass&& other) [move assignment]
~MyClass() [destructor]
------------- func5 with lvalue ----------------------------------
MyClass(const MyClass& other) [copy constructor]
MyClass& operator=(MyClass&& other) [move assignment]
~MyClass() [destructor]
------------- func5 with std::move(lvalue) -----------------------
MyClass& operator=(MyClass&& other) [move assignment]
==================================================================

In the case of lvalue, instead of calling the copy assignment directly, a temporary object will be created by calling the copy constructor operator, and then the move assignment operator is called; which is more inefficient than just calling copy assignment operator without creating a temporal object (which is what func3 did by duplicating code).

From what I understand, at the moment there is no totally equivalent method to avoid code duplication.

CodePudding user response:

Think about the following example:

void foo(int) {}
void foo(double) {}

void bar(double x) {
    foo(x);
}

int main() {
    bar(0);
}

In the above program, foo(double) will always be called, not foo(int). This is because while the argument was originally an int, this information is irrelevant once you are inside bar. bar only sees its own parameter x, which has type double regardless of what the original argument type was. Therefore, it calls the overload of foo that is the best match for the type of the parameter x.

Your func2 works similarly:

void func2(MyClass &el, const MyClass &c) {
    el = c;
}

Here, the expression c is an lvalue, even though the reference may have been bound to a temporary object at the time of calling. Because of that, the compiler must select the = operator that takes an lvalue as its right argument.

In order to forward lvalues as lvalues and rvalues as rvalues, overloading with const MyClass& and MyClass&& is often used, even though (as you note) it is duplicative. For some suggestions on how to reduce code duplication, see Is There A Way To Remove Duplicate Code While Providing lvalue and rvalue Overloads? and How do I prevent code repeat between rvalue and lvalue member functions?

  • Related