0

I've come across some behavior I cannot wrap my head around regarding rvalue return. Let's say we have the following structs:

struct Bar
{
   int a;

   Bar()
      : a(1)
   {
      std::cout << "Default Constructed" << std::endl;
   }

   Bar(const Bar& Other)
      : a(Other.a)
   {
      std::cout << "Copy Constructed" << std::endl;
   }

   Bar(Bar&& Other)
      : a(Other.a)
   {
      std::cout << "Move Constructed" << std::endl;
   }

   ~Bar()
   {
      std::cout << "Destructed" << std::endl;
   }

   Bar& operator=(const Bar& Other)
   {
      a = Other.a;
      std::cout << "Copy Assigment" << std::endl;
      return *this;
   }

   Bar& operator=(Bar&& Other) noexcept
   {
      a = Other.a;
      std::cout << "Move Assigment" << std::endl;
      return *this;
   }
};

struct Foo
{
   Bar myBar;

   Bar GetBar()
   {
      return myBar;
   }

   // Note that we are not returning Bar&&
   Bar GetBarRValue()
   {
      return std::move(myBar);
   }

   Bar&& GetBarRValueExplicit()
   {
      return std::move(myBar);
   }
};

Being used as followed:

int main()
{
   Foo myFoo;

   // Output:
   // Copy Constructed
   Bar CopyConstructed(myFoo.GetBar());

   // Output:
   // Move Constructed
   Bar MoveConstructedExplicit(myFoo.GetBarRValueExplicit());

   // Output:
   // Move Constructed
   //
   // I don't get it, GetBarRValue() has has the same return type as GetBar() in the function signature.
   // How can the caller know in one case the returned value is safe to move but not in the other?
   Bar MoveConstructed(myFoo.GetBarRValue());
}

Now I get why Bar MoveConstructedExplicit(myFoo.GetBarRValueExplicit()) calls the move constructor. But since the function Foo::GetBarRValue() does not explicitly returns a Bar&& I expected its call to give the same behavior as Foo::GetBar(). I don't understand why/how the move constructor is called in that case. As far as I know, there is no way to know that the implementation of GetBarRValue() casts myBar to an rValue reference.

Is my compiler playing optimization tricks on me (testing this in debug build in Visual Studio, apparently return value optimizations cannot be disabled)? What I find slightly distressing is the fact that the behavior on the caller's side can be influenced by the implementation of GetBarRValue(). Nothing in the GetBarRValue() signature tells us it will give undefined behavior if called twice. Seems to me because of this it's bad practice to return std::move(x) when the function does not explicitly returns a &&.

Can someone explain to me what is happening here? Thanks!

UncleBen
  • 82
  • 7
  • FWIW, if the compiler can see the definition (i.e., it's in the same source file, or its in the header file), then the compiler can know that it does an `std::move` – ChrisMM Mar 30 '21 at 22:36
  • I thought that was inlining too at first, but then even with the declaration and definition in separate .h and .cpp files the behavior is same. – UncleBen Mar 31 '21 at 03:45

2 Answers2

2

What's happening is you are seeing elision there. You are move-constructing on return std::move(x) with a simple type of Bar; then the compiler is eliding the copy.

You can see the non-optimized assembly of GetBarRValue here. The call to the move constructor is actually happening in the GetBarRValue function, not upon returning. Back in main, it's just doing a simple lea, it's not at all calling any constructor.

ChrisMM
  • 8,448
  • 13
  • 29
  • 48
  • This makes sense, it also explains why the output was much less verbose than I expected. Thanks! – UncleBen Mar 31 '21 at 03:48
  • By using the -fno-elide-constructors compiler option in the godbolt link you shared, I get the output I was expecting. This confirms it. – UncleBen Mar 31 '21 at 03:57
0

The key point is that

   Bar myBar;

is a Foo's data member. Therefore, to each of Foo's member function, its time of living is longer than theirs. In other words, each of these functions returns a value or a reference to a value whose scope is larger than that of the function.

Now,

Bar GetBar()
   {
      return myBar;
   }

The compiler can "see" that you return a value that will live after the function has finished. The function must return its value "by value", and since its argument is certainly not a temporary, the compiler will chose the copy constructor.

If you experimented with this function like this:

Bar GetBar()
   {
      Bar myBar; // shadows this->myBar
      return myBar;
   }

the compiler should notice that the scope of the return value is expiring, so it would change its "kind" from l-value to r-value and use a move constructor (or copy elision, but it's a different story).

The second function:

   Bar GetBarRValue()
   {
      return std::move(myBar);
   }
 

Here the compiler can "see" the same return value as before: the value must be passed "by value". However, the programmer has changed the "kind" of myBar from l-value to x-value (object that is addressable, but can be treated as a temporary). This means: "Hey, compiler, the state of myBar needs no longer be protected, you can steal its contents". The compiler will obediently chose the move constructor. Because you, the programmer, let "him" do so.

In the third case,

   Bar&& GetBarRValueExplicit()
   {
      return std::move(myBar);
   }

The compiler will do no conversion, no constructor will be invoked. Just a reference (a "pointer in disguise") of kind "r-value reference" will be returned. Then, this value will be used to initialize an object, MoveConstructed, and this is where the move constructor will be invoked, based on the type of its argument.

zkoza
  • 2,644
  • 3
  • 16
  • 24
  • This answer is inexact. What I observed was copy-elision, which is applied across translation units. Your explanation about compilers "seeing" inside function's scope is only valid if we consider the code to be in the same translation unit. Granted, I did not specify it, but I tested this in a more complex setup with multiple translation units. Also what you are wrong about: ```Bar GetBar() { Bar myBar; // shadows this->myBar return myBar; }``` Both implementation would call a move-constructor on the caller side, but it is elluded in both cases. – UncleBen Mar 31 '21 at 05:45
  • @UncleBen You asked explicitly: "How can the caller know in one case the returned value is safe to move but not in the other?" To my understanding, this question has nothing to do with copy elision but with a choice between copy and move constructors. BTW, copy/move elision is mandatory for `Bar CopyConstructed(myFoo.GetBar());` because the function involved in object initialization returns a pr-value. Had you asked about unexpectedly small verbosity of the output, my answer would have been completely different. – zkoza Mar 31 '21 at 12:26
  • 1
    And if you want to understand why it does not matter wheher caller and callee are in the same or different compilation units, watch this: [C++Now 2018: Jon Kalb “Copy Elision”](https://www.youtube.com/watch?v=fSB57PiXpRw) – zkoza Mar 31 '21 at 13:36
  • Thanks I'll certainly will! :) The thing is, I was wrong, the caller doesn't know it is safe to move in one case but not in the other. The output I was getting were generated from inside the function scope when we copy/move the return value. In the caller's scope we always tries to move the rvalue returned by GetBar(), but the move is eluded. – UncleBen Mar 31 '21 at 15:55