Copy Elision: Move Constructor Not Called When Using Ternary Expression in Return Statement

copy elision: move constructor not called when using ternary expression in return statement?

The specification for the conditional operator is so complicated it is scary. But I believe that your compiler is correct in its behavior. See 5.16 [expr.cond]/p4:

If the second and third operands are glvalues of the same value
category and have the same type, the result is of that type and value
category ...

Also see 12.8 [class.copy], p31, b1 which describes when copy elision is allowed:

in a return statement in a function with a class return type, when
the expression is the name of a non-volatile automatic object (other
than a function or catch-clause parameter) with the same cv-
unqualified type as the function return type, the copy/move operation
can be omitted ...

The expression is not the name of an automatic object, but is a conditional expression (and that conditional expression is an lvalue). So copy elision is not allowed here, and there is nothing else that says that the lvalue expression can pretend to be an rvalue for overload resolution.

C++ Ternary Operator Calls Copy Constructor Instead of Move Constructor

The "automatic" move in return statement is limited:

From Automatic move from local variables and parameters:

If expression is a (possibly parenthesized) id-expression that names a variable whose type is either
[..]

It is not the case for return ex == "123" ? a : b;.

Then normal way is done, ex == "123" ? a : b return a l-value, so copy happens.

You might do

return std::move(ex == "123" ? a : b);

or

return ex == "123" ? std::move(a) : std::move(b);

to have manual move.

using if allows to follows the above rules with automatic move

if (ex == "123")
return a;
else
return b;

Visual Studio not performing RVO when ternary operator is used and move/copy ctors are deleted

Is it a bug?

Yes. This is a bug in MSVC. Pretty much every other compiler that supports C++17 compiles it. Below we have the assembly produced by:

  • ellcc https://godbolt.org/z/PfzDTs
  • gcc https://godbolt.org/z/oXpDyk
  • clang https://godbolt.org/z/KX99Yc
  • power64 AT12.0 https://godbolt.org/z/XvWiEa
  • icc 19.0.1 https://godbolt.org/z/pZWBJ5

And all of them compile it with -std=c++17 or -std=c++1z (for ellcc).

What does the standard say?

Conditional expressions (the ones formed by the ternary operator) produce values according to these rules (see section 8.5.16).

Paragraph 1 of 8.5.16 describes sequencing, and parts 2 through 7 describe the value category of the resulting expression (see section 8.2.1 for a description of value categories).

  • Paragraph 2 covers the case that either the second or third operands are void.
  • Paragraph 3 covers the case that both the second and third operands are glvalued bitfields (i.e., not prvalues)
  • Paragraph 4 covers the case the second and third operands have different types
  • Paragraph 5 covers the case that the second and third operands are glvalues of the same type (also not prvalues)
  • Paragraph 6:

Otherwise, the result is a prvalue. If the second and third operands do not have the same type, and either has (possibly cv-qualified) class type, overload resolution is used to determine the conversions (if any) to be applied to the operands (16.3.1.2, 16.6). If the overload resolution fails, the program is ill-formed. Otherwise, the conversions thus determined are applied, and the converted operands are used in place of the original operands for the remainder of this subclause.

This gives us our answer. The result is a prvalue, so it's unnecessary to use the copy or move constructors, as the value will be instantiated in the memory provided by the calling function (this memory location is passed as a "hidden" parameter to your function).

Does your program implicitly refer to the move constructor or the copy constructor?

Jon Harper was kind enough to point out that the standard states:

A program that refers to a deleted function implicitly or explicitly, other than to declare it, is ill-formed. (11.4.3.2)

This begs the question: does your program implicitly refer to the move constructor or the copy constructor?

The answer to this is no. Because the result of the conditional expression is a prvalue, no temporary is materialized, and as a result neither the move constructor or the copy constructor is referenced, either explicitly or implicitly. To quote cppreference (emphasis mine):

Under the following circumstances, the compilers are required to omit the copy and move construction of class objects, even if the copy/move constructor and the destructor have observable side-effects. The objects are constructed directly into the storage where they would otherwise be copied/moved to. The copy/move constructors need not be present or accessible, as the language rules ensure that no copy/move operation takes place, even conceptually:

  • In a return statement, when the operand is a prvalue of the same class type (ignoring cv-qualification) as the function return type:

    T f() { return T(); }

    f(); // only one call to default constructor of T

  • In the initialization of a variable, when the initializer expression is a prvalue of the same class type (ignoring cv-qualification) as the variable type:

T x = T(T(f())); // only one call to default constructor of T, to initialize x

Distinguishing between NRVO and RVO

One source of contention is whether or not Copy Elision is guaranteed. It is important to distinguish between Named Return Value Optimization, and pure Return Value Optimization.

If you return a local variable, it's not guaranteed. This is Named Return Value Optimization. If your return statement is an expression that's a prvalue, it is guaranteed.

For example:

NoCopyOrMove foo() {
NoCopyOrMove myVar{}; //Initialize
return myVar; //Error: Move constructor deleted
}

I am returning a an expression (myVar) that is the name of an object of automatic storage. In this case, return value optimization is permitted but not guaranteed. Section 15.8.3 of the standard applies here.

On the other hand, if I write:

NoCopyOrMove foo() {
return NoCopyOrMove(); // No error (C++17 and above)
}

Copy Elision is guaranteed, and no copy or move takes place. Similarly, if I write:

NoCopyOrMove foo(); //declare foo
NoCopyOrMove bar() {
return foo(); //Returns what foo returns
}

Copy Elision is still guaranteed because the result of foo() is a prvalue.

Conclusion

MSVC does, in fact, have a bug.

Guaranteed copy elision and deleted copy/move constructor when throwing an exception

Your understanding of the standard is correct, in so far as your analysis of that paragraph actually applies.

But it doesn't apply, because no constructor is ever considered for copy-initialization.

Guaranteed elision is something of a misnomer; it's a useful explanation of the concept, but it doesn't reflect exactly how it works as far as the standard is concerned. Guaranteed elision works by rewriting the meaning of prvalues so there never is a copy/move to be elided in the first place.

A a = A{}; is copy-initialization, but it doesn't even hypothetically call a copy/move constructor. The variable a is initialized by the prvalue's initializer:

The result of a prvalue is the value that the expression stores into its context. A prvalue whose result is the value V is sometimes said to have or name the value V. The result object of a prvalue is the object initialized by the prvalue

a is the "result object" initialized by the prvalue.

The same goes here. A{} is a prvalue. The exception object is the "result object" to be initialized by the prvalue. There's no temporary object, no copy/move constructors that ever get considered for usage.

Why does a moved class object not trigger the move constructor when bound to function parameter?

When foo takes A&&, you are binding to a r-value reference and not making any new A objects that need construction.

This is because std::move is basically just a cast to r-value reference.

When foo takes A, you are passing a r-value reference to A as a means of constructing A. Here, the move constructor is chosen as it takes A&& as its argument.

Can we use the return value optimization when possible and fall back on move, not copy, semantics when not?

When the expression in the return statement is a non-volatile automatic duration object, and not a function or catch-clause parameter, with the same cv-unqualified type as the function return type, the resulting copy/move is eligible for copy elision. The standard also goes on to say that, if the only reason copy elision was forbidden was that the source object was a function parameter, and if the compiler is unable to elide a copy, the overload resolution for the copy should be done as if the expression was an rvalue. Thus, it would prefer the move constructor.

OTOH, since you are using the ternary expression, none of the conditions hold and you are stuck with a regular copy. Changing your code to

if(b)
return x;
return y;

calls the move constructor.

Note that there is a distinction between RVO and copy elision - copy elision is what the standard allows, while RVO is a technique commonly used to elide copies in a subset of the cases where the standard allows copy elision.

Should we write `std::move` in the cases when RVO can not be done?

What happen in your example is not linked to RVO, but to the ternary operator ?. If you rewrite your example code using an if statement, the behavior of the program will be the one expected. Change foo definition to:

Test foo(int param)
{
Test test1;
Test test2;
if (param > 5)
return std::move(test2);
else
return test1;
}

will output Test(Test&&).


What happens if you write (param>5)?std::move(test1):test2 is:

  1. The ternary operator result is deduced to be a prvalue [expr.cond]/5
  2. Then test2 pass through lvalue-to-rvalue conversion which causes copy-initialization as required in [expr.cond]/6
  3. Then the move construction of the return value is elided [class.copy]/31.3

So in your example code, move elision occurs, nevertheless after the copy-initialization required to form the result of the ternary operator.

Why is move constructor not picked when returning a local object of type derived from the function's return type?

[class.copy]/32 continues:

[...] if the type of the first parameter of the selected constructor
is not an rvalue reference to the object's type (possibly
cv-qualified), overload resolution is performed again, considering the
object as an lvalue.

The first overload resolution, treating d as an rvalue, selects Base::Base(Base&&). The type of the first parameter of the selected constructor is, however, not Derived&& but Base&&, so the result of that overload resolution is discarded and you perform overload resolution again, treating d as an lvalue. That second overload resolution selects the deleted copy constructor.



Related Topics



Leave a reply



Submit