Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Returning an argument passed by rvalue reference

If I have a class A and functions

A f(A &&a)
{
  doSomething(a);
  return a;
}
A g(A a)
{
  doSomething(a);
  return a;
}

the copy constructor is called when returning a from f, but the move constructor is used when returning from g. However, from what I understand, f can only be passed an object that it is safe to move (either a temporary or an object marked as moveable, e.g., using std::move). Is there any example when it would not be safe to use the move constructor when returning from f? Why do we require a to have automatic storage duration?

I read the answers here, but the top answer only shows that the spec should not allow moving when passing a to other functions in the function body; it does not explain why moving when returning is safe for g but not for f. Once we get to the return statement, we will not need a anymore inside f.

Update 0

So I understand that temporaries are accessible until the end of the full expression. However, the behavior when returning from f still seems to go against the semantics ingrained into the language that it is safe to move a temporary or an xvalue. For example, if you call g(A()), the temporary is moved into the argument for g even though there could be references to the temporary stored somewhere. The same happens if we call g with an xvalue. Since only temporaries and xvalues bind to rvalue references, it seems like to be consistent about the semantics we should still move a when returning from f, since we know a was passed either a temporary or an xvalue.

like image 752
Jeremy B. Avatar asked Jun 17 '16 01:06

Jeremy B.


People also ask

Can you pass an rvalue by reference?

You can pass an object to a function that takes an rvalue reference unless the object is marked as const . The following example shows the function f , which is overloaded to take an lvalue reference and an rvalue reference. The main function calls f with both lvalues and an rvalue.

Can you return an rvalue?

Returning a value from a function will turn that value into an rvalue. Once you call return on an object, the name of the object does not exist anymore (it goes out of scope), so it becomes an rvalue. Similarly, calling a function will give back an rvalue. The return value of a function has no name.

Can a function return an lvalue?

Why is it not possible to set a function returning an address while it is possible to set a function that returns a reference. Short answer: You return a pointer by-value and anything returned by-value is (by definition) not an lvalue.

What does double ampersand mean in C++?

&& is a new reference operator defined in the C++11 standard. int&& a means "a" is an r-value reference. && is normally only used to declare a parameter of a function. And it only takes an r-value expression. Simply put, an r-value is a value that doesn't have a memory address.


1 Answers

Second attempt. Hopefully this is more succinct and clear.

I am going to ignore RVO almost entirely for this discussion. It makes it really confusing as to what should happen sans optimizations - this is just about move vs copy semantics.

To assist this a reference is going to be very helpful here on the sorts of value types in c++11.

When to move?

lvalue

These are never moved. They refer to variables or storage locations that are potentially being referred to elsewhere, and as such should not have their contents transferred to another instance.

prvalue

The above defines them as "expressions that do not have identity". Clearly nothing else can refer to a nameless value so these can be moved.

rvalue

The general case of "right-hand" value, and the only thing that's certain is they can be moved from. They may or may not have a named reference, but if they do it is the last such usage.

xvalue

These are sort of a mix of both - they have identity (are a reference) and they can be moved from. They need not have a named variable. The reason? They are eXpiring values, about to be destroyed. Consider them the 'final reference'. xvalues can only be generated from rvalues which is why/how std::move works in converting lvalues to xvalues (through the result of a function call).

glvalue

Another mutant type with its rvalue cousin, it can be either an xvalue or an lvalue - it has identity but it's unclear if this is the last reference to the variable / storage or not, hence it is unclear if it can or cannot be moved from.

Resolution Order

Where an overload exists that can accept either a const lvalue ref or rvalue ref, and an rvalue is passed, the rvalue is bound otherwise the lvalue version is used. (move for rvalues, copy otherwise).

Where it potentially happens

(assume all types are A where not mentioned)

It only occurs where an object is "initialized from an xvalue of the same type". xvalues bind to rvalues but are not as restricted as pure expressions. In other words, movable things are more than unnamed references, they can also be the 'last' reference to an object with respect to the compiler's awareness.

initialization

A a = std::move(b); // assign-move
A a( std::move(b) ); // construct-move

function argument passing

void f( A a );
f( std::move(b) );

function return

A f() {
    // A a exists, will discuss shortly
    return a;
}

Why it will not happen in f

Consider this variation on f:

void action1(A & a) {
    // alter a somehow
}

void action2(A & a) {
    // alter a somehow
}

A f(A && a) {
    action1( a );
    action2( a );
    return a;
}

It is not illegal to treat a as an lvalue within f. Because it is an lvalue it must be a reference, whether explicit or not. Every plain-old variable is technically a reference to itself.

That's where we trip up. Because a is an lvalue for the purposes of f, we are in fact returning an lvalue.

To explicitly generate an rvalue, we must use std::move (or generate an A&& result some other way).

Why it will happen in g

With that under our belts, consider g

A g(A a) {
    action1( a ); // as above
    action2( a ); // as above
    return a;
}

Yes, a is an lvalue for the purposes of action1 and action2. However, because all references to a only exist within g (it's a copy or moved-into copy), it can be considered an xvalue in the return.

But why not in f?

There is no specific magic to &&. Really, you should think of it as a reference first and foremost. The fact that we are demanding an rvalue reference in f as opposed to an lvalue reference with A& does not alter the fact that, being a reference, it must be an lvalue, because the storage location of a is external to f and that's as far as any compiler will be concerned.

The same does not apply in g, where it's clear that a's storage is temporary and exists only when g is called and at no other time. In this case it is clearly an xvalue and can be moved.


rvalue ref vs lvalue ref and safety of reference passing

Suppose we overload a function to accept both types of references. What would happen?

void v( A  & lref );
void v( A && rref );

The only time void v( A&& ) will be used per the above ("Where it potentially happens"), otherwise void v( A& ). That is, an rvalue ref will always attempt to bind to an rvalue ref signature before an lvalue ref overload is attempted. An lvalue ref should not ever bind to the rvalue ref except in the case where it can be treated as an xvalue (guaranteed to be destroyed in the current scope whether we want it to or not).

It is tempting to say that in the rvalue case we know for sure that the object being passed is temporary. That is not the case. It is a signature intended for binding references to what appears to be a temporary object.

For analogy, it's like doing int * x = 23; - it may be wrong, but you could (eventually) force it to compile with bad results if you run it. The compiler can't say for sure if you're being serious about that or pulling its leg.

With respect to safety one must consider functions that do this (and why not to do this - if it still compiles at all):

A & make_A(void) {
    A new_a;
    return new_a;
}

While there is nothing ostensibly wrong with the language aspect - the types work and we will get a reference to somewhere back - because new_a's storage location is inside a function, the memory will be reclaimed / invalid when the function returns. Therefore anything that uses the result of this function will be dealing with freed memory.

Similarly, A f( A && a ) is intended to but is not limited to accepting prvalues or xvalues if we really want to force something else through. That's where std::move comes in, and let's us do just that.

The reason this is the case is because it differs from A f( A & a ) only with respect to which contexts it will be preferred, over the rvalue overload. In all other respects it is identical in how a is treated by the compiler.

The fact that we know that A&& is a signature reserved for moves is a moot point; it is used to determine which version of "reference to A -type parameter" we want to bind to, the sort where we should take ownership (rvalue) or the sort where we should not take ownership (lvalue) of the underlying data (that is, move it elsewhere and wipe the instance / reference we're given). In both cases, what we are working with is a reference to memory that is not controlled by f.

Whether we do or not is not something the compiler can tell; it falls into the 'common sense' area of programming, such as not to use memory locations that don't make sense to use but are otherwise valid memory locations.

What the compiler knows about A f( A && a ) is to not create new storage for a, since we're going to be given an address (reference) to work with. We can choose to leave the source address untouched, but the whole idea here is that by declaring A&& we're telling the compiler "hey! give me references to objects that are about to disappear so I might be able to do something with it before that happens". The key word here is might, and again also the fact that we can explicitly target this function signature incorrectly.

Consider if we had a version of A that, when move-constructing, did not erase the old instance's data, and for some reason we did this by design (let's say we had our own memory allocation functions and knew exactly how our memory model would keep data beyond the lifetime of objects).

The compiler cannot know this, because it would take code analysis to determine what happens to the objects when they're handled in rvalue bindings - it's a human judgement issue at that point. At best the compiler sees 'a reference, yay, no allocating extra memory here' and follows rules of reference passing.

It's safe to assume the compiler is thinking: "it's a reference, I don't need to deal with its memory lifetime inside f, it being a temporary will be removed after f is finished".

In that case, when a temporary is passed to f, the storage of that temporary will disappear as soon as we leave f, and then we're potentially in the same situation as A & make_A(void) - a very bad one.

An issue of semantics...

std::move

The very purpose of std::move is to create rvalue references. By and large what it does (if nothing else) is force the resulting value to bind to rvalues as opposed to lvalues. The reason for this is a return signature of A& prior to rvalue references being available, was ambiguous for things like operator overloads (and other uses surely).

Operators - an example

class A {
    // ...
  public:
    A & operator= (A & rhs); // what is the lifetime of rhs? move or copy intended?
    A & operator+ (A & rhs); // ditto
    // ...
};

int main() {
    A result = A() + A(); // wont compile!
}

Note that this will not accept temporary objects for either operator! Nor does it make sense to do this in the case of object copy operations - why do we need to modify an original object that we are copying, probably in order to have a copy we can modify later. This is the reason we have to declare const A & parameters for copy operators and any situation where a copy is to be taken of the reference, as a guarantee that we are not altering the original object.

Naturally this is an issue with moves, where we must modify the original object to avoid the new container's data being freed prematurely. (hence "move" operation).

To solve this mess along comes T&& declarations, which are a replacement to the above example code, and specifically target references to objects in the situations where the above won't compile. But, we wouldn't need to modify operator+ to be a move operation, and you'd be hard pressed to find a reason for doing so (though you could I think). Again, because of the assumption that addition should not modify the original object, only the left-operand object in the expression. So we can do this:

class A {
    // ...
  public:
    A & operator= (const A & rhs); // copy-assign
    A & operator= (A && rhs); // move-assign
    A & operator+ (const A & rhs); // don't modify rhs operand
    // ...
};

int main() {
    A result = A() + A(); // const A& in addition, and A&& for assign
    A result2 = A().operator+(A()); // literally the same thing
}

What you should take note of here is that despite the fact that A() returns a temporary, it not only is able to bind to const A& but it should because of the expected semantics of addition (that it does not modify its right operand). The second version of the assignment is clearer why only one of the arguments should be expected to be modified.

It's also clear that a move will occur on the assignment, and no move will occur with rhs in operator+.

Separation of return value semantics and argument binding semantics

The reason that there is only one move above is clear from the function (well, operator) definitions. What's important is we are indeed binding what is clearly an xvalue / rvalue, to what is unmistakably an lvalue in operator+.

I have to stress this point: there is no effective difference in this example in the way that operator+ and operator= refer to their argument. As far as the compiler is concerned, within either's function body the argument is effectively const A& for + and A& for =. The difference is purely in constness. The only way in which A& and A&& differ is to distinguish signatures, not types.

With different signatures come different semantics, it's the compiler's toolkit for distinguishing certain cases where there otherwise is no clear distinction from the code. The behavior of the functions themselves - the code body - may not be able to tell the cases apart either!

Another example of this is operator++(void) vs operator++(int). The former expects to return its underlying value before an increment operation and the latter afterwards. There is no int being passed, it's just so the compiler has two signatures to work with - there is just no other way to specify two identical functions with the same name, and as you may or may not know, it is illegal to overload a function on just the return type for similar reasons of ambiguity.

rvalue variables and other odd situations - an exhaustive test

To understand unambiguously what is happening in f I've put together a smorgasbord of things one "should not attempt but look like they'd work" that forces the compiler's hand on the matter almost exhaustively:

void bad (int && x, int && y) {
  x += y;
}
int & worse (int && z) {
  return z++, z + 1, 1 + z;
}
int && justno (int & no) {
  return worse( no );
}
int num () {
  return 1;
}
int main () {
  int && a = num();
  ++a = 0;
  a++ = 0;
  bad( a, a );
  int && b = worse( a );
  int && c = justno( b );
  ++c = (int) 'y';
  c++ = (int) 'y';
  return 0;
}

g++ -std=gnu++11 -O0 -Wall -c -fmessage-length=0 -o "src\\basictest.o" "..\\src\\basictest.cpp"

..\src\basictest.cpp: In function 'int& worse(int&&)':
..\src\basictest.cpp:5:17: warning: right operand of comma operator has no effect [-Wunused-value]
   return z++, z + 1, 1 + z;
                 ^
..\src\basictest.cpp:5:26: error: invalid initialization of non-const reference of type 'int&' from an rvalue of type 'int'
   return z++, z + 1, 1 + z;
                          ^
..\src\basictest.cpp: In function 'int&& justno(int&)':
..\src\basictest.cpp:8:20: error: cannot bind 'int' lvalue to 'int&&'
   return worse( no );
                    ^
..\src\basictest.cpp:4:7: error:   initializing argument 1 of 'int& worse(int&&)'
 int & worse (int && z) {
       ^
..\src\basictest.cpp: In function 'int main()':
..\src\basictest.cpp:16:13: error: cannot bind 'int' lvalue to 'int&&'
   bad( a, a );
             ^
..\src\basictest.cpp:1:6: error:   initializing argument 1 of 'void bad(int&&, int&&)'
 void bad (int && x, int && y) {
      ^
..\src\basictest.cpp:17:23: error: cannot bind 'int' lvalue to 'int&&'
   int && b = worse( a );
                       ^
..\src\basictest.cpp:4:7: error:   initializing argument 1 of 'int& worse(int&&)'
 int & worse (int && z) {
       ^
..\src\basictest.cpp:21:7: error: lvalue required as left operand of assignment
   c++ = (int) 'y';
       ^
..\src\basictest.cpp: In function 'int& worse(int&&)':
..\src\basictest.cpp:6:1: warning: control reaches end of non-void function [-Wreturn-type]
 }
 ^
..\src\basictest.cpp: In function 'int&& justno(int&)':
..\src\basictest.cpp:9:1: warning: control reaches end of non-void function [-Wreturn-type]
 }
 ^

01:31:46 Build Finished (took 72ms)

This is the unaltered output sans build header which you don't need to see :) I will leave it as an exercise to understand the errors found but re-reading my own explanations (particularly in what follows) it should be apparent what each error was caused by and why, imo anyway.

Conclusion - What can we learn from this?

First, note that the compiler treats function bodies as individual code units. This is basically the key here. Whatever the compiler does with a function body, it cannot make assumptions about the behavior of the function that would require the function body to be altered. To deal with those cases there are templates but that's beyond the scope of this discussion - just note that templates generate multiple function bodies to handle different cases, while otherwise the same function body must be re-usable in every case the function could be used.

Second, rvalue types were predominantly envisioned for move operations - a very specific circumstance that was expected to occur in assignment and construction of objects. Other semantics using rvalue reference bindings are beyond the scope of any compiler to deal with. In other words, it's better to think of rvalue references as syntax sugar than actual code. The signature differs in A&& vs A& but the argument type for the purposes of the function body does not, it is always treated as A& with the intention that the object being passed should be modified in some way because const A&, while correct syntactically, would not allow the desired behavior.

I can be very sure at this point when I say that the compiler will generate the code body for f as if it were declared f(A&). Per above, A&& assists the compiler in choosing when to allow binding a mutable reference to f but otherwise the compiler doesn't consider the semantics of f(A&) and f(A&&) to be different with respect to what f returns.

It's a long way of saying: the return method of f does not depend on the type of argument it receives.

The confusion is elision. In reality there are two copies in the returning of a value. First a copy is created as a temporary, then this temporary is assigned to something (or it isn't and remains purely temporary). The second copy is very likely elided via return optimization. The first copy can be moved in g and cannot in f. I expect in a situation where f cannot be elided, there will be a copy then a move from f in the original code.

To override this the temporary must be explicitly constructed using std::move, that is, in the return statement in f. However in g we're returning something that is known to be temporary to the function body of g, hence it is either moved twice, or moved once then elided.

I would suggest compiling the original code with all optimizations disabled and adding in diagnostic messages to copy and move constructors to keep tabs on when and where the values are moved or copied before elision becomes a factor. Even if I'm mistaken, an un-optimized trace of the constructors / operations used would paint an unambiguous picture of what the compiler has done, hopefully it will be apparent why it did what it did as well...

like image 184
Xeren Narcy Avatar answered Sep 22 '22 05:09

Xeren Narcy