Value categories and copy elision

Published Date:

The goal of this article is to understand the nuances of value categories and copy elision (aka RVO/NRVO). A deeper understanding of how C++ handles variables will lead to better (and correct) use of const, lvalue and rvalue.

const

Before we talk about anything, let's clear the confusion around const [1] [2] .

What is const ? This is just a keyword that tells the compiler, "Hey this thing should never change during the course of the execution." But is that all? Consider these different uses of const -

class X {
public:
    int f() const {...}
    const int g() const {...}
    int const h() const {...}
    int i() {...}
private:
    int x = 1;
}

int main() {
    const X x;
    x.i();  // Error: Cannot call a non-const member on a const object
}

How about a const on a member function? Member functions can be const-qualified which means that they can't modify any of the member variable of the class during the execution. This has certain implications, the chief among those being that you cannot call a non-const member function on a const object.

Note: You can actually change some of the member variable in a const member function if they are declared mutable . [3]

But, who does a const belong to? Say in the example int * const p , is p a pointer to a const int , or is p a const pointer to an int ? The former says that we cannot change the value pointed by p and the latter says that we cannot change what p points to. The thing to note here is that const always looks to its left. So in this case const looks to int * and says yup, I am a const to int * . So, latter is the case this time.

Then what about const int * const p . Ah, this you should never use [4] (like in the above member function g() as well). But, what actually happens is, const looks to its left finds nothing and says "Hmm, I'll compromise and go with my right". So this is equivalent to int const * const p which should now be clear that "p is a const pointer to a const integer".

Aside: This debate of where to put const is known as "East const v/s Const west". [4]

Ok, then what about const int const * const p ? At this point, are you thinking you wanna throw another const in there? Joke aside, this is actually an error at which compiler should say that you are using a duplicate const. So, finally the rule of thumb is [5] -

const modifies what is on its left. Unless there is nothing on its left, in which case it modifies what’s on its right. If you consistently place const after what it modifies, the rule becomes much simpler: const modifies what is on its left.

Value categories

There have been many articles written on this topic, each slightly wrong from the others, or slightly more technical to be comprehensible. This is another attempt at slightly less wrong and slightly less technical, but still probably incomprehensible to some. The most common way to define value categories [6] -

An lvalue (locator value) represents an object that occupies some identifiable location in memory (i.e. has an address). rvalues are defined by exclusion, by saying that every expression is either an lvalue or an rvalue.

Alright, that's it, seems easy. If it can have an address, then it's lvalue otherwise it's not. This enables the reference return types as well as they are lvalues, ex -

int someGlobal = 2;
int& f() {
    return someGlobal;
}

int main() {
    f() = 3;
    std::cout << someGlobal << std::endl;
}

const and lvalues

Things get a little complicated when we start to think about const . As we discussed we cannot assign to const , then how can they be lvalues? and that brings us to our first gotcha. "Modifiable lvalues" are what we are going to refer to the things which are modifiable, duh. So, const variable is a "non-modifiable lvalue", it's still an lvalue just not a modifiable one.

Conversion between lvalues and rvalues

Now, we have clear boundaries between lvalues (modifiable or non-modifiable) and rvalues, but are there scenarios where we convert them to one another. Of course there are!

The most common way to convert rvalue to lvalue is when you are dereferencing a pointer. The pointer value in itself is an rvalue, it's just some integer, but combined with * , it becomes an lvalue which can be assigned to.

And the opposite of this is getting the address of an lvalue, which is a rvalue. Also, lvalues are converted to rvalues all the time. Whenever we use a variable on the right side of the assignment operator we are using an lvalue as a rvalue.

The mess with temporaries and const

We know that temporaries and constants are rvalues, since they don't have any address associated with them. Say we have the following function -

class MyType {
private:
    int x[100000];
public:
    MyType() {}
};

void f(MyType& x, MyType& y, int& z) {
    // Some operations on these which do not modify the original values
}

Now, all is fine but we cannot call this function with temporaries or constants such as f(MyType(), MyType(), 4); . The first two arguments are temporaries and the last one is a constant. If you try to compile this, the compiler is going to throw an error and this should be expected. We are trying to get an lvalue (which is reference in our case) from an rvalue, temporary or constant. How can C++ allow this and maintain the correctness? It doesn't make any assumption where you are storing the temporary or constant and allowing that to be bound to an lvalue is just calling for problems down the line.

Now, what is the actual problem here that C++ does not allow this behaviour? Is it that the arguments are lvalues or that you can modify those? It's the possibility that the passed arguments can be modified that C++ does not allow temporaries or constants to be passed to the function. So, if we just make the arguments unmodifiable, that should be good, right? Indeed it is and the following will compile just fine -

void f(MyType const& x, MyType const& y, int const& z) {}


int main() {
    f(MyType(), MyType(), 4);
}

Rvalue references

Rvalue references were introduced in C++11 for the purpose of solving two specific issues -

The de facto standard to read about this is [7] . If you got half an hour additional to spare, go read that. Promise it won't take more than that on a concentrated read.

If you are still here, then let us discuss, make sure you get yourself a coffee.

Move semantics

Consider a simple example -

class MyType {
public:
  MyType() {
    std::cout << "Constructor" << std::endl;
  }

  MyType(const MyType& other) {
    std::cout << "Copy Constructor" << std::endl;
  }

  MyType& operator=(const MyType& other) {
    std::cout << "Copy operator" << std::endl;
    return *this;
  }

  ~MyType() {
    std::cout << "Destructor" << std::endl;
  }
};


MyType f() {
  MyType y;
  return y;
}

int main() {
  MyType x;
  x = f();
}

Compile this using g++ --std=c++03 -fno-elide-constructors ...

I am simply creating an object of type MyType in f() and then passing that on to x variable. The flow will be something like -

  1. Inside main : Construct MyType object x
  2. Inside f : Construct MyType object y
  3. Returning from f : Copy y to the temporary return
  4. Returning from f : Destroy x
  5. Inside main : Copy temporary return to x
  6. Inside main : Destroy temporary
  7. Returning from main : Destroy x

This is extremely bad! To create one object from a factory function we did two copies and created 3 objects.

The issue at hand is that the overloaded copy constructor and operator does not allow us to modify the arguments. That's what we established in the previous section. But, if we know that those things are just temporaries and will be destroyed in just a second, shouldn't we be able to simply move the data (supposing the object holds pointers to other heavy objects) from the temporary to our object and avoid copying members.

This is exactly the problem that rvalue reference allows us to avoid. We can overload copy constructor and operator for move semantics, which will allow us to move data from one object to another. Note that the objects will still be created, just that we will be using move semantic overloads now for copy constructor and copy operator.

// Note: Double ampersand means a rvalue reference
MyType(MyType&& other) {
    std::cout << "Move constructor" << std::endl;
}

MyType& operator=(MyType&& other) {
    std::cout << "Move operator" << std::endl;
    return *this;
}

After adding these overloads, compile with g++ -std=c++11-fno-elide-constructors ... Now when you run it, you'll see that the copy calls have been replaced with move calls. Isn't C++ just wonderful?

But, what is rvalue reference?

We have an rvalue passed into our overloaded operator or constructor or any other function for that matter. But, it is a fully qualified variable now with its own address, so can it still remain an rvalue? Nope!

We need more categories than just lvalues, rvalues and hence they were introduced in C++11 [8] [9]

Based on this, our rvalue reference can now point to xvalue, temporary or const. But the question remains, what is rvalue reference itself? To clear this up, let's look at the following program -

void h(MyType& x) {
    std::cout << "Inside lvalue reference h" << std::endl;
}

void h(MyType&& x) {
    std::cout << "Inside rvalue reference h" << std::endl;
}

void g(MyType& x) {
    std::cout << "Inside lvalue reference g" << std::endl;
    h(x);
}

void g(MyType&& x) {
    std::cout << "Inside rvalue reference g" << std::endl;
    h(x);
}

int main() {
    g(MyType());
}

Clearly, g is being called with a temporary, so an rvalue and it will go to the right function call. But, g further calls h , now what will that call resolve to, the rvalue version of h or the lvalue version? Turns out, it resolves to lvalue version of h , because a named rvalue reference is an lvalue by itself. This is pretty bad and we should not do this as a temporary object now becomes modifiable which leads to undefined behaviour in C++.

Before we see how to fix this, let's see a case where a rvalue reference might not be named -

MyType&& construct() {
    ...
    return MyType();
}

int main() {
    MyType a = construct();
}

std::move

std::move is introduced for solving this specific issue. It maintains the rvalue reference type, or in other words changes an lvalue to an rvalue reference type. Let me say that again CHANGES LVALUE TO RVALUE REFERENCE. Ok, lets first fix our previous example -

void g(MyType&& x) {
    std::cout << "Inside rvalue reference g" << std::endl;
    h(std::move(x));
}

Now, on to the implications of that statement. It can change any lvalue to rvalue reference type. This is amazing and scary. Consider this example -

int main() {
    MyType x;
    g(x);  // This calls the lvalue version of g
    g(std::move(x));  // This calls the rvalue version of g
}

This can be used even for stack objects (well all objects are stack objects somehow) and after moving x in the above example if we try to access it, that is undefined behaviour, and so the scary part. When we issue std::move(x) , we are assuring compiler that this is an expiring value, even when it's not.

Aside: Under the hood, std::move is simply static_cast .

Lifetime of temporaries

Consider the following code -

int main() {
    char const* c = std::string("layog").c_str();
    // Use c after this
}

The above code will compile just fine (maybe a compiler warning on latest versions, I saw some of them catching this issue now) but will not run fine. std::string("layog") is a temporary which is deleted when the full expression has been evaluated, so, c becomes a dangling pointer after the evaluation of the statement. CPP reference says this about temporaries lifetime [10] -

All temporary objects are destroyed as the last step in evaluating the full-expression that (lexically) contains the point where they were created, and if multiple temporary objects were created, they are destroyed in the order opposite to the order of creation. This is true even if that evaluation ends in throwing an exception.

This BTW is an extremely common bug (I don't have reference to back this, but have fallen to this quite many times myself). Generally happens when some function is returning a std::string as -

std::string f() {
    // Forms a new string and returns that
}

int main() {
    char const* c = f().c_str();
}

Rvalue reference name considered harmful

You should watch [11] if you have some additional time. Consider the following example -

template<typename T>
void f(T const& m) {
    std::cout << "Called f with const lvalue reference" << std::endl;
}

template<typename T>
void f(T&& m) {
    std::cout << "Called f with rvalue reference" << std::endl;
}

int main() {
    MyType x;
    const MyType y;
    f(x); // 1
    f(y); // 2
    f(MyType()) // 3
}

After running this, you'll find that only the call 2 f(y) resolves to the "f with const lvalue reference". Isn't it strange that the call 1 f(x) resolves to the rvalue reference?

And that's why it is dangerous to use the name "rvalue reference" whereas in reality this can bind to an lvalue reference as well. Why this is the case will be discussed in the next section, but for now the point to note is

then you can bind an lvalue to this seemingly rvalue reference.

Scott Meyers like to call it "Universal Reference" rather than Rvalue reference [11] . So, if both your conditions are satisfied, consider them Universal references, rather than rvalue references.

Perfect forwarding

Now, why in the hell the standard committee do this atrocious thing? Well, when they were designing rvalue references they had two goals in mind -

To understand perfect forwarding consider the following example (don't run it yet, but consider it from what is described till now) -

template<typename T>
void f(T const &) {...}

template<typename T>
void f(T&) {...}

template<typename T>
void f(T&&) {...}

The first template will resolve to any call to a const lvalue reference, the second will resolve to any non-const lvalue reference and lastly, the third will resolve to any rvalue reference. Now, if I skip one of these calls, what will happen -

In short, for all the template references in function arguments, you'll need to supply all the overloaded versions, which becomes a maintainability nightmare for the developer as now you have to provide 3n overloads, where n is the number of templated arguments.

The standard committee thought, "Hmm, we anyway are introducing this new token && which we have been calling rvalue references, what if it can also resolve to lvalues under certain conditions", and lo and behold, the collapsing rules were invented. What these rules allow is to let rvalue universal references refer to both lvalue and rvalue. So, in our case the third template is enough to resolve the other two and we don't need to provide the overloads. The rules are as follows -

In short, as Stephan T. Lavavej likes to call "lvalue references are infectious".

But, now like we saw in the case of move semantics, we will lose the information about the original object, whether it was an lvalue or a rvalue. So, to aid in that we have std::forward which will recover the original value category of the object. So, our final version of the f function will be -

template<typename T>
void f(T&& x) {
    h(std::forward<T>(x));
}

This is known as perfect forwarding. In case of rvalue reference T will resolve to MyType and in case of lvalue reference T will resolve to MyType& and collapsing rules will make MyType& && to MyType& .

Note: You cannot declare object as MyType&& & or any other combination. You cannot explicitly invoke a type collapsing rule, and trying to do so will unleash compiler's wrath.

Places where type collapsing happens

There are basically 4 ways compiler does type collapsing -

Copy elision

Now, that we understand the various ways "we" can control the copy/move of objects, let's understand how compiler even trumps us and writes much better code than we can from the available tools [12] .

Return Value Optimization

Continuing our example of MyType and create another function which constructs the object for us

MyType f() {
    return MyType();
}

int main() {
    MyType x = f();
}

Compile this program in two different ways -

When you run both the program, you'll notice that in the second version only one object is ever created and there is no need to copy/move the object around. This is due to "return value optimization" enabled by "copy elision". What this is that when a compiler recognizes that you are returning by value and the returned value is a temporary, why go through all the hassle of creating a temporary and why not simply construct the object at the address of x.

Named Return Value Optimization

This is exactly same as above, just the construction of object doesn't need to be in the return statement and can be constructed via some local variable as -

MyType f() {
    MyType y;
    return y;
}

Here we are constructing the object first and then returning it, making it named return value. The optimization done is same, but this is a bit more difficult to recognize for the compilers (though modern compilers are brilliant and does this most of the time).

Aside: How this is actually achieved is explored by [13] a bit. Looking at generated assembly, it seems that the compiler passes a reference of the actual object to the function and then constructs the object at the address of the actual object.

On prvalue

Till C++14, there was no concept of prvalue , sure the standard mentions it, but a true prvalue never existed. Any object that you create as a const or temporary was immediately converted to an xvalue and then moved around the code using the move semantics. This was generally elided by the compilers so temporaries are never even created in the first place but the point still remains.

From C++17 onwards, the standard guarantees that prvalues are actually prvalues and a prvalue won't materialize until certain conditions are met [14] . Since, prvalues do not materialize they are just an elusive idea of some object which will be created after you assign them to some variable [15] . Consider -

std::string a() {
    return "a pony";
}

std::string b() {
    return a();
}

int main() {
    auto x = b();
}

In case of copy elision, the compiler pass the variable x to b and then further to a where that is constructued using the string "a pony". But, what C++17 guarantees is that "a pony" isn't materialized in b then neither in a but finally in main . So acc. to C++17 this is equivalent to auto x("a pony") ;

Epilogue - Trust compilers

Watch [16] if you got an hour, it truely is a wonderful talk.

The problem that [13] faced was that the author was trying to do something like -

MyType f() {
    return MyType();
}

int main() {
    MyType x = std::move(f());
}

This looks fine but that std::move snatches the opportunity from the compiler to optimize this code to the fullest. The std::move converts an lvalue to an rvalue reference which the compiler unfortunately have to honor. So, if this wasn't there, the compiler will just create one object due to (N)RVO, but since we are explicitly moving the object the compiler now have to create atleast two objects, and hence the degradation in performance.

NOTE: I was using -std=c++11 because it is now compulsory for compilers to do RVO in C++17 [17] (partially true, check the "On prvalue" section above) and the demo wouldn't have been possible. I even heard that they are trying to make NRVO compulsory in C++20 [18] .

References

  1. Const - Friend or Foe in C++?

    Malte Langkabel

  2. Const Correctness

  3. cv type qualifiers

  4. Join the East Const revolution!

    Marius Bancila

  5. A Foolish Consistency

    Jon Kalb

  6. Understanding lvalues and rvalues in C and C++

    Eli Bendersky

  7. C++ Rvalue References Explained

    Thomas Becker

  8. Value Categories

  9. What are rvalues, lvalues, xvalues, glvalues, and prvalues?

  10. Lifetime

  11. C++ and Beyond 2012: Scott Meyers - Universal References in C++11

    Scott Meyers

  12. What are copy elision and return value optimization?

  13. But I was helping the compiler!

    Pankaj Raghav

  14. Temporary materialization

  15. Guaranteed Copy Elision Does Not Elide Copies

    Sy Brand

  16. KEYNOTE: What Everyone Should Know About How Amazing Compilers Are - Matt Godbolt [C++ on Sea 2019]

    Matt Godbolt

  17. Guaranteed copy elision through simplified value categories

    Richard Smith

  18. Guaranteed copy elision for named return objects

    Anton Zhilin