Why does copy elision make an exception for formal parameters?
Asked Answered
U

2

1

Here is a complete program:

#include <iostream>
using std::cout;
using std::endl;
using std::move;

int count {0};  // global for monitoring


class Triple {
public:
    Triple() = default;    // C++11 use default constructor despite other constructors being declared
    Triple(Triple&&) = default;
    Triple(const Triple& t) :    // copy constructor
        Triple(t.mX, t.mY, t.mZ) {
        count++;
    }

    Triple(const int x, const int y, const int z) :
        mX{ x }, mY{ y }, mZ{ z } {
    }

    const Triple& operator +=(const Triple& rhs) {
        mX += rhs.mX;
        mY += rhs.mY;
        mZ += rhs.mZ;
        return *this;
    }

    int x() const;
    int y() const;
    int z() const;

private:
    int mX{ 0 };    // c++11 member initialization
    int mY{ 0 };
    int mZ{ 0 };
};


#if 0
inline Triple operator+(const Triple& lhs, const Triple& rhs) {
    Triple left { lhs };
    left += rhs;
    return left;
}
#else
inline Triple operator+(Triple left, const Triple& rhs) {
    left += rhs;
    return left;
}
#endif

int main()
{
    Triple a,b;

    cout << "initial value of count is: " << count << endl;

    auto result { a+b };

    cout << "final value of count is: " << count << endl;
}

Of interest is the fact that the copy constructor has a side effect, and there are two versions of the operator+ to contemplate.

Case 1

inline Triple operator+(const Triple& lhs, const Triple& rhs) {
    Triple left { lhs };
    left += rhs;
    return left;
}

Case 2

inline Triple operator+(Triple left, const Triple& rhs) {
    left += rhs;
    return left;
}

Visual Studio 2015 gives the same result for either, printing a result of 1. However gcc 4.8.4 gives 2 for Case 1.

This summary of copy elision states “which isn't the function parameter” which makes me suppose that VS is wrong. Is that correct?

But, why are formal parameter names treated specially in this rule? Why isn't it exactly like any other local variable?

(I'm not saying that the optimizer would figure things out depending on calling conventions and in light of separate compilation of caller and call-ee, but merely why it's not allowed.)

Edit: if outputting 1 is correct, how does that fit with the elision rule?


Note ※: I found this text is copied from §12.8 paragraph 31 in the publicly available N3690.

Upholsterer answered 18/11, 2015 at 21:33 Comment(10)
Which GCC version are we talking about? (And N3690 is ancient.)Lentissimo
Can't reproduce. I tried all 6 combinations on rextester.com/runcode (three compilers times two flavors of operator+), and all six print 1.Bund
I'll get back later with exacting details of compiler.Martyrize
What happens is that, rather than being copied, the passed-by-value parameter is moved into the result. Demo. See also DR1148Bund
Your demo also gives "1".Martyrize
Yes. You asked whether VC is wrong when it supposedly elides copying of the formal parameter, in violation of the standard. I show that a) it is not in violation - the standard allows moving the formal parameter, and VC does just that, and b) other compilers do the same (gcc demo; clang demo).Bund
"the passed-by-value parameter is moved into the result" how's that not in contradiction of what I quoted, where the formal parameter is not allowed to be aliased with the return value?Martyrize
How is it in contradiction, in your opinion? moved != aliasedBund
See also 12.8p32 (the very next paragraph) - this is what permits, and in fact requires, the return value to be move-constructed (as opposed to copy-constructed) from the parameter.Bund
Ah, so I (and the person who showed me this) should instrument the move constructor as well.Martyrize
D
1

First of all, understand that RVO and NRVO are opportunities offered by the writers of the standard to writers of compilers. A given compiler is free to ignore the possibility of RVO or NRVO if it can't make it work, if it doesn't know if it can make it work, if the moon is full, etc.

In this case, though, it's easy. The way (N)RVO is fundamentally implemented, is by constructing the return value directly into the memory occupied by the return value, or even the memory occupied by the variable which will be set to that return value.

That is, the potential savings from (N)RVO come not just from the ability to elide copy construction, but the ability to reduce copying in general.

But when the source of the return value is a function parameter, it's too late for that. left is already somewhere in memory, and the return value has to go somewhere else. Short of some brute-force as-if-ruling from inlining, copying is already a given, because a second object has already been constructed.

Denary answered 19/11, 2015 at 23:59 Comment(1)
That is the calling convention and separate compilation preventing the NRVO from being practical. But that does not explain why there's a rule against it: for some calling convention mechanisms, inlining, link-time-code-generation etc. there is no "too late". I wrote "(I'm not saying that the optimizer would figure things out depending on calling conventions and in light of separate compilation of caller and call-ee, but merely why it's not allowed.)" and your answer says "it's not a good opportunity".Martyrize
R
1

If copy elision is disabled, both cases consist of 1 copy followed by 3 moves. (Any output of 2 for your code would indicate a compiler bug).

The copy is:

  • initialization of left

and the moves are:

  • initialization of return value from left
  • initialization of temporary object denoted by a+b from return value
  • initialization of result from a+b

It would be more illuminating to replace count with an output message saying "in copy constructor" and "in move constructor" . Currently you are not tracking moves at all.

In the pass-by-reference case , all 3 moves can be elided. In the pass-by-value case, 2 of the moves can be elided. The move which cannot be elided is the move from left to the return value.

I don't know the rationale for why this move can't be elided. Maybe it'd be difficult for a compiler to do something like A a = foo( A() ); if A() were elidable all the way up to a.

Rufe answered 20/11, 2015 at 0:24 Comment(3)
NB. If you want to test without copy elision, use -fno-elide-constructors in gcc. To test with no move semantics, do not declare the move constructor at all, and add in ~Triple(){} which suppresses implicit generation of the move-constructor. Also note that VS (before 2015) has a bug where move-constructors are not implicitly generated even without the user-declared destructor.Rufe
@Upholsterer Oh right. Yeah, my last paragraph is naffRufe
Being difficult for the compiler - the compiler not caring to handle such a case - is not a reason to prohibit it.Martyrize

© 2022 - 2024 — McMap. All rights reserved.