Unintuitive RVO of function returning non-copyable const value?

431 Views Asked by At

Consider the following example code in C++ >=17:

struct A{
    A() = default;
    A(const A&) = delete;
};

const A f(){ return A{}; }

int main(){
    const A& a = f(); // OK
    // A& b = f();    // Error: cannot convert 'const A' to 'A&'
    const A c = f();  // OK: Copy elision
    A d = f();        // OK!?
}

The class A is non-copyable, but because of the mandatory copy-elision, we can put the result of f() into variables. According to this page in cppreference.com, the above behavior is perfectly legitimate, since it is specified that the const quantifier on the returning value is ignored when copy-elision happens.

However, this behavior seems very counterintuitive to me. Since A is non-copyable, I feel like there should be no way to turn const A into A (except perhaps when you have A::A(const A&&) constructor). Is this a well-thought decision, or is this considered a defect in language specification?

(I have encountered this problem when trying to implement my own type-erasure class. The whole purpose of me specifying const on the return value of function f() was to prevent the user from getting a non-const lvalue reference of the object, but this specification seems to open a hole.)

Edit: This example might show the counter-intuition more clearly: Let's consider a class A that is movable but not copyable.

struct A{
    A() = default;
    A(const A&) = delete;
    A(A&&) = default;
};

int main(){
    // C++14: move / C++17: copy elision
    A a = A{}; 

    // C++14: error (deleted copy constructor) / C++17: copy elision(!!)
    A b = static_cast<const A>(A{}); 
}

This does not compile in C++ <=14, but compiles in C++ >=17. In a common case where a class is movable but not copyable, const meant there were no means to get a non-const object out of it before C++14, but it doesn't anymore (as long as const is added to a prvalue).

2

There are 2 best solutions below

7
On

It would be a breaking change to reject such an initialization with a movable object, since prior versions of the language would produce a move there. Making it depend on the cv-qualification of the variable would have been very subtle.

For a copyable object, the new behavior is actually a subset of the old: the copy from the const A return value to the A variable could have been elided, in which case they were just as much the same object as in C++17.

Meanwhile, const return values have been somewhat frowned upon since C++11, where f(return_const()) lost the ability to move into a (by-value) parameter.

The C++17 treatment of prvalues (“mandatory copy elision” is a name that makes sense only historically) enables yet other cases, like the return of non-movable objects in this example: the function is thought to specify how to initialize the object it “returns”, rather than actually returning a finished object. In choosing this model, it was generally considered more important to support more kinds of efficient code than to support existing idioms for preventing misuse.

0
On

Elision is merging multiple objects into one.

struct Bob {};

Bob foo() {
  const Bob b;
  return b;
}

const Bob x = foo();

here, the objects b, x and the anonymous return value of foo() are elided into the same object.

This object varies in constness depending on which declaration you use. The C++ standards committee decided that this efficiency gain was more than worth the const appearing and disappearing.

Now, in this case, guaranteed elision doesn't hold, as const Bob b cannot be guaranteed elided.

const Bob bar() {
  return {};
}
Bob foo() {
  return bar();
}

const Bob x = foo();

here we have guaranteed elision, and all 3 (or 4 depending) objects become the same object.