12

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).

eivour
  • 1,678
  • 12
  • 20
  • I think 3 & 4 are copy initialisation (object is created in place and no copy/move is required) see (1) in https://en.cppreference.com/w/cpp/language/copy_initialization _"...First, if T is a class type and the initializer is a prvalue expression whose cv-unqualified type is the same class as T, the initializer expression itself, rather than a temporary materialized from it, is used..."_ – Richard Critten Apr 24 '21 at 18:08
  • Yes, I agree. However, I wonder why they made the decision to ignore cv-qualification of the prvalue when they put it into c++17. – eivour Apr 24 '21 at 18:17
  • 3
    Well, cv-qualifiers on return-types aside from class/union types are ignored. And they don't make all that much sense where allowed either. This is just another nail in the coffin. – Deduplicator Apr 24 '21 at 18:39
  • @Deduplicator Even when it's not a return value from a function, when you explicitly `static_cast` a non-copyable object into `const`, it is ignored by the compiler... (I added this as the second example) – eivour Apr 24 '21 at 19:04
  • I wonder if it's really necessary to preserve and force the constness of an r-value to the l-value constructed from it. I don't see any danger in that, but I may be wrong. – MatG Apr 24 '21 at 19:14
  • @MatG Let's say we want to make our own (mutable) string class, having a `char*` inside it. We will make it completely deep-const, i.e. modification to the content of char array is possible only when one has the non-const pointer. With some type-erasure on releasing the memory, we could make the class handle both cases where the class owns the internal array and where it doesn't (i.e. an existing C-string is provided externally and we want to read/modify it using the class). However, if we want to handle both cases where the external string is `const` and non-`const` using the same class, – eivour Apr 24 '21 at 19:52
  • @MatG (cont.) we are in trouble. One natural way is to `const_cast` away external `const char*`, and then make sure that every instance of the string class is always `const`. Because we have made the class completely deep-const, we are safe as long as we don't have non-const reference to the object. But being unable to preserve constness of r-value, it is not possible to guarantee that the user does not get an l-value of the object. – eivour Apr 24 '21 at 19:55
  • There's a similar question here, but the answer is incomplete: https://stackoverflow.com/questions/14429988/can-a-c-compiler-perform-rvo-for-a-const-return-value – Etienne Laurin Apr 24 '21 at 22:10
  • @AtnNn In that question, we are dealing with a copyable object (`std::string`), and without RVO the compiler would generate a copy before C++14. Of course the compiler is free to elide the copy there, but it was important that a copy constructor *exists* to convert a `const` value to a non-`const` value. In C++17, it feels intuitive that a non-`const` prvalue could be used to initialize a non-`const` variable without copy constructor, but it doesn't feel intuitive that even a `const` prvalue could be used to initialize a non-`const` variable when the copy constructor is explicitly deleted. – eivour Apr 25 '21 at 03:34

2 Answers2

2

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.

Davis Herring
  • 36,443
  • 4
  • 48
  • 76
  • Would C++14 produce a move on `const` prvalues of objects that define `A::A(A&&)`? I thought that was to be rejected because we don't have an appropriate move constructor (which is `A::A(const A&&)`). – eivour Apr 25 '21 at 03:17
  • @eivour: No, but it would *copy* it if allowed, and it would be allowed to elide that copy, which has much the same effect as the 17 rule. – Davis Herring Apr 25 '21 at 03:33
  • @Daviis Herring I see. But I didn't see the point in also making `const` to non-`const` copy elision *mandatory* (i.e. without checking existence of copy constructor). Even if the *mandatory* copy elision happened only in non-`const` to non-`const`, `const` to `const`, or non-`const` to `const` case, the compiler would still be free to optimize `const` to non-`const` case when we have the copy constructor, and that would not break any existing code while getting all new usage cases covered. – eivour Apr 25 '21 at 03:48
  • (cont.) And it feels (to me) a lot more *consistent* that way, because we would preserve the `const`ness in the sense that we cannot get a non-`const` value/reference out of a `const` value/reference if we don't have a constructor for it. – eivour Apr 25 '21 at 04:01
  • @eivour: Eliding the copy would (equally) break your string class that relies on the copy to establish a writable buffer. – Davis Herring Apr 25 '21 at 05:17
  • Even before C++14 the compiler *may or may not* elide the copy if you have a copy constructor, so I think making it mandatory wouldn't count as "breaking" a C++14 code. – eivour Apr 25 '21 at 09:07
  • @eivour: I didn’t mean C++17 would break it, but that your string class, if copyable, would fail to protect its external string even in C++14 (if the compiler happened to elide the copy). – Davis Herring Apr 25 '21 at 15:33
0

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.

Yakk - Adam Nevraumont
  • 262,606
  • 27
  • 330
  • 524