Some Thoughts about Rvalue References in C++
Eric Lengyel • November 13, 2017
C++ has always had these things called lvalues and rvalues that refer to expressions that can typically appear on the left or right side of an assignment. In C++11, rvalues were divided into two groups called xvalues (“expiring” rvalues) and prvalues (“pure” rvalues) so that the mess now known as rvalue references could be implemented in order to achieve move semantics and perfect forwarding.
A common example of using an rvalue reference to move an object is that of string concatenation. Consider the following code.
string x = ...; string y = ...; string z = x + y;
The expression x + y
yields a temporary, unnamed string object that is then copied into the string z
. Before C++11, there was no
way for the assignment operator for the string
class to know whether the right side of the assignment was a temporary object, so it would always
have to copy the contents of the string. With the addition of rvalue references, a new assignment operator could be declared like this:
string& operator =(string&& s);
The expression x + y
is an xvalue, and xvalues can be bound to rvalue references, which are declared with the double ampersand &&
.
The above operator is called only when the string s
corresponds to a temporary object or something that was explicitly cast to an rvalue reference.
In this case, the operator knows that the object s
is transient, meaning that preserving its contents is not important, so it is free to steal those
contents (usually by assuming ownership of the memory) and leave s
in an empty (but valid) state. This avoids an extra memory allocation and copy.
So what’s the problem? Because this was all done by a perverse mutilation of the reference syntax, the information about whether a variable is an rvalue reference is peeled off whenever its evaluated in an expression. Consider a class with a subobject:
class A {...}; class B { A a; B(B&& b) : a(b.a) {} };
Here, the expression b.a
has lost the information that b
was an rvalue reference. The moment b
appears in the expression,
it’s converted to a plain value of type B
. As a result, the ordinary copy constructor would be called for the A
subobject, which is
not what was intended when the enclosing object is being moved. To fix this embarrassing failure, C++ introduced the std::move()
function template,
which basically casts its argument right back to an rvalue reference, and special case language was added to the standard to make sure it stayed that way long enough
to be passed into another function. To work correctly, the above copy constructor must be implemented as follows:
B(B&& b) : a(std::move(b.a)) {}
It’s up to the programmer to insert std::move()
everywhere that a move is appropriate, and no error or warning will be issued if they’re left out.
If you forget to use std::move()
somewhere in a complex object, then you’ll end up moving some parts and copying others.
The addition of rvalue references in C++11 also required some new template argument deduction rules and reference collapsing rules that allow for perfect forwarding.
I’m not going to talk about these except to say that I think they, along with the requirement that std::move()
be used, represent a set of inelegant
hacks that are necessary only because the fundamental design direction of rvalue references was misguided.
An alternative design
I know it’s too late to change the language, but I’ve recently been thinking about what I call “reverse qualifiers”, and I believe introducing one of these for transient objects would have yielded a better design than introducing the rvalue reference syntax. A lot of ugly bandages and special case language could have been avoided in the standard.
What’s a reverse qualifier? First, recall that a qualifier is a keyword added to an object’s type that provides some kind of information about the storage
used by the object. C++ defines the qualifiers const
and volatile
, and these can be implicitly added to the type of an object as it is passed
around your code, but they can only be removed through an explicit cast operation. A reverse qualifier is similar, but it has the constraint that it can be implicitly
removed from an object’s type, but only explicitly added. I talked about the reverse qualifier called disjoint
in an earlier post about aliasing.
Here, I’d like to talk about a reverse qualifier called transient
. Because rvalue references have been cemented into C++, I don’t expect this qualifier
to ever be adopted, but I wanted to write down the following ideas in case future researchers found themselves contemplating move semantics for a different language.
Let’s imagine a version of C++ in which rvalue references don’t exist, but we now have the transient
qualifier. (I will drop the “reverse” from here on.)
All temporary objects (and basically anything that C++11 calls an xvalue) would automatically have the transient
qualifier applied to it, but this qualifier can
always be implicitly removed, so temporary objects could still be used anywhere that non-transient objects are expected.
In the string concatenation example, the temporary object produced by the expression x + y
has the type transient string
, which is a different
type than plain string
just as const string
is different than string
. A move assignment operator for the string
class would be declared as follows.
string& operator =(transient string& s);
This function would be the best match in overload resolution when the object on the right side of the assignment is a temporary object. If this move assignment operator
hadn’t been declared, then the transient
qualifier could be implicitly removed, and the best match would be the ordinary copy assignment operator.
Because the transient
qualifier is part of the type, it propagates to subobjects just like the const
qualifier would. In the case of the move
constructor for the class B
above, we can safely write this:
B(transient B& b) : a(b.a) {}
The type of b.a
is transient A
, so the move constructor for the A
subobject is automatically the best match if it exists,
and there is no longer a need for something like std::move()
. The language naturally does the right thing, and the programmer is relieved from the burden
of manually ensuring the propagation of move semantics.
Something interesting that would be possible with the transient
qualifier that is currently not possible in C++ is to explicitly declare a named object
to be transient. It’s also possible to have pointers to transient objects. I obviously haven’t had a chance to try this out because it’s not implemented
anywhere, but I could see how the following code might be useful.
void ConsumeObject(transient X& x); void foo() { transient X object; object.CalculateSomething(params...); ... ConsumeObject(object); }
The storage for X
could intentionally be used as a temporary location to perform some complex work, and then ownership of the contents of X
would be transferred elsewhere when ConsumeObject()
was called.