RVO (Return Value Optimization) and deleted move constructors pitfall.

Xiahua Liu January 14, 2025 #Linux

The Surprising Case

// filepath: rvo_example.cpp
class Widget {
public:
    Widget() = default;
    Widget(const Widget&) = default;      // Copy constructor
    Widget(Widget&&) = delete;            // Deleted move constructor
    
    int value{42};
};

Widget createWidget() {
    Widget w;
    return w;  // Error: call to deleted move constructor
}

Why This Happens

Based on the C++ standards here:

Under the following circumstances, the compilers are permitted, but not required to omit the copy and move(since C++11) construction of class objects even if the copy/move(since C++11) constructor and the destructor have observable side-effects. The objects are constructed directly into the storage where they would otherwise be copied/moved to. This is an optimization: even when it takes place and the copy/move(since C++11) constructor is not called, it still must be present and accessible (as if no optimization happened at all), otherwise the program is ill-formed:

And because of this:

In a return statement or a throw expression, if the compiler cannot perform copy elision but the conditions for copy elision are met, or would be met except that the source is a function parameter, the compiler will attempt to use the move constructor even if the source operand is designated by an lvalue(until C++23) the source operand will be treated as an rvalue(since C++23); see return statement for details.

In human words

Solution

The solution is simple, we can remove the = delete declaration, and because Widget has a copy constructor, the move constructor will not be implicitedly declared. So the compiler will not use the move constructor of Widget in this case, because it is not present, and will use the copy constructor instead.

// filepath: solution.cpp
class Widget {
public:
    Widget() = default;
    Widget(const Widget&) = default;
    // Remove the deleted move constructor
    // Let compiler handle move operations
    
    int value{42};
};

// Now works fine - uses copy constructor when NRVO not applied
Widget createWidget() {
    Widget w;
    return w;
}

Other notes : Avoid side effects in copy/move constructors

Copy elison allows the compiler to omit the copy/move constructor while returning an object, this means if the copy/move constructor has side effects, the compiler is allowed to ignore those side effects when copy elison happens.

This means in general your C++ code should not have copy/move constructor with side effects, such as the shared_ptr copy constructor. Otherwise the behavior will be undefined.