value categories

June 28, 2025

if I had a dime for every constructor call, I’d have no dimes in scenario 1 but a single one in scenario 2. Which is weird, because I’m not a C++ expert


Introduction to the introduction

I’ve read totw/117 multiple times, and each time I read it it becomes more confusing. Here’s my own take with far more scope and detail. Although it is the machinations of my own mind. So that’s your warning.

Introduction

When we write interesting code, we use variables. These take on many forms throughout languages, but the core idea is that we associate some name to a value, and can update this value [no functional programming sorry]. In C++, this looks like int x = 5, giving us an integer x to which we’ve assigned the value 5. We can then go on to write expressions like int y = x + 5 and x = x + y or x = f(x, y). Notably, we cannot write something like x + y = 5, because we could interpret x + y = 5 in different ways, with none of them making much sense. Maybe we want to constrain x and y such that they are equal to 5. How do we know what to change x and y to? Perhaps we understand that x and y refer to memory locations where we save the value, so we are doing some pointer arithmetic. Then what about -x = 5? Surely a negative memory location doesn’t make sense.

To be clear, nobody has probably ever suggested we be able to handle a case like x + y = 5, because this is not what we want to do with variables. But this suggests a fundamental difference between the left and the right of an assignment: we have two classes, historically dubbed “lvalues” and “rvalues” for the obvious observation that one can appear on the left hand side of an assignment, whereas the other only appears on the right.

So, an lvalue more or less means something that designates a location where we can assign things into. And an rvalue refers to an expression that can only appear on the right hand side of an assignment. More helpfully, rvalues typically refer to temporaries or things that do not necessarily have an address.

C++11

why isn’t it called c++[+++++++++++]?

Being the language it is, C++ changed the way we looked at things in C++11. While I don’t know how it worked previously, value categories post C++11 exist at the compiler level and on syntactical expressions, and not at runtime.

The biggest change that C++11 offers is the addition of new value categories: in addition to lvalues and rvalues, we not have glvalues, xvalues, and prvalues. glvalues [“generalized” lvalues], refer to expressions that point to an object that exists in memory, so as we expect, lvalues are a subset of glvalues.

The category of rvalues has been split into prvalues [“pure”-rvalues], and xvalues [“eXpring”-rvalues]. Our old definition of rvalues, being temporaries like x + y and 5 are now categorized under prvalues, while xvalues is an entirely new category.

This fact is emphasized when we see that an xvalue is not only an rvalue, it is also a glvalue. The intersection of glvalues and rvalues is not empty then, and their intersection are exactly xvalues.

Move semantics

std::move out of my basement - me in 25 years

The main motivation of this new taxonomy of value categories is move semantics. Move semantics, at a high level, exist so objects can when their resources can be reused. Their name is a hint to that—expiring values, meaning that we expect the object to be destroyed more or less immediately after the expression it lives in.

Consider the following code:

class Person {
public:
    Person(const std::string &name) : m_name(name) {}
private:
    std::string name;
};

int main() {
  Person p{“fred”}; // <–creates a temporary and copies this string!
}

You can see the problem. Now a 4 character string is not large and may not be slow, but you can imagine we have a million length data vector here. Or even that since we have to copy, we’ll need twice the memory, even though we’re about to throw one copy away. In any case, it’d be really nice to be able to reuse that memory, because in this case especially, it’s about to get destroyed.

We’d like a way to indicate to the compiler that we can reuse a value! This really means that we need to be able to differentiate rvalues from lvalues.

We can already accept only lvalues in a function call with a signature!

void f(int& x);

f(1); // error!

int x = 0;
f(x); // okay

Then, we need something that only accepts rvalues. In C++ fashion, we do this in a way that requires more explaination in the standard.

We call int& an lvalue reference to an int, and we’ll call int&& an rvalue reference to an int. So more or less two different types of references, but we happened to have settled on the && syntax to suggest there’s a reference going on.

How do these things work? More or less what you would expect by the naming: lvalues can bind to lvalue references, rvalues can bind to rvalue references, and mixing is not allowed.

void f(int& x);
void g(int&& x);

f(1); // error!
g(1); // ok!

int x = 0;
f(x); // okay!
g(x); // error!

perfect! So if we create a temporary, we’ll be able to pass it in and we know it’s a temporary.

Let’s say we’re implementing a tensor class, in the machine learning sense, whichin effect is a high dimensional array of floats.

class Tensor {
private:
    // store other stuff we’ll ignore
    float* storage;
public:
    // we ignore the normal constructors
    Tensor(Tensor&& t) {
        storage = t.storage;
        t.storage = nullptr; // if our destructor is RAII
    }
};

How do we trigger this constructor? As in, where will we get an rvalue Tensor? Here are two example ways to do this:

So, if we call

Tensor getTensor();
Tensor t{Tensor{ctor...}};
Tensor t{getTensor()};

These will use the move constructor as we hoped and save the copy we might otherwise have to do.

However, we are still missing half the story: we know how to consume something that is expiring, but can we make things expiring? After all, if we can reuse resources why can’t we reuse an existing object already? Well we can - we just need to tell the compiler to use the correct constructor.

Imagine the following example:

std::vector<int> v;
// do something with t

std::vector<int> newOwner{v}; // we want to transfer ownership of the data, done with t

We have a vector v, and we do some calculations. Somewhere down the line, we want to give ownership to a new variable. How do we do this? As is, this won’t do what we want: we see v is a vector<int>&, and we can bind it to the copy constructor that uses const vector<int>&. This is not what we want!

What we want is the compiler to use the move constructor, which takes a vector<int>&&. Let’s just force it to do that!

std::vector<int> v;
// do something

std::vector<int> newOwner{static_cast<std::vector<int>&&>(v)};

This will call the move constructor! Because we cast v into an rvalue reference (to an xvalue), we allow the move constructor to bind. notice that this doesn’t actually do anything to v, we simply change which constructor is called and the move constructor will modify v in a defined way.

static_cast<std::vector<int>&&>(v) is both ugly and annoying to write, so let’s write a shorthand:

template <class T>
T&& move_cast(T& t) {
    return static_cast<T&&>(t);
};

//

std::vector<int> newOwner{move_cast(v)};

Oh wait, this is std::move [up to removing references and a universal reference, keep reading bud]—so we just use

std::vector<int> newOwner{std::move(v)};

std::move is just a cast that converts lvalues into xvalues, which can bind to rvalue reference types: allowing us to designate when objects can are expiring and their resources can be reused.

There’s a few perspectives on what exactly std::move is, and for those familiar with ownership through Rust may be confused with this: we can use after move! This surely is a bug! UB?

No. So says Herb Stutter. And it’s true! There’s nothing preventing you from using v in the above context even after you move from it. It is defined, and maybe it should not be done. But the move does not end a lifetime in the C++ sense, the lifetime is only over after the scope ends or we call delete on a pointer. So in Herb’s eyes, a moved-out-of function should be seen as simply an object we have called a function on. Which again, is true. This is the way C++ has chosen to implement moves. It is simply a function, and does not have special behavior beyond calling that function when we want to reuse resources.

Again, this is how C++ has decided to do it, and it is the way things will likely look moving forward. Personally, I think something moved from is as good as dead, and should not be used. Standard containers are guaranteed to be in a “valid but unspecified” state post move. One cool thing that Rust can do because it enforces these things is that we can skip the destructor call on something like v—this saves us plenty of overhead and maybe even kernel calls!

C++17

insert the same joke but a few more ’+’

Some readers might note that our examples in the previous section may not even call the move constructor. Or the copy constructor for that matter.

Pop quiz! In C++17, how many constructor calls are made here?

Tensor getTensor() {
    // do something
    return Tensor{...}; // some ctor
}

Tensor t = Tensor{getTensor()};

The answer is 1. The copy constructor and move constructor could even be deleted for all the compiler and language cares.

Why? In C++17, the standard updates the conditions for when prvalues [read: temporaries!] are instantiated. Previously, they may or may not have been created as temporaries and copied/move from. However, the new standard says that they are not instantiated until they are materialized into their final destination. In the above, the return statement generates a prvalue, which is constructed into a prvalue, which finally gets assigned to a variable location. Thus, the compiler will skip the intermediates and directly initialize the Tensor t with the return value, and everyone goes home happy!

Fun fact, binding to an rvalue reference does cause the prvalue to materialize

The following calls [or any function call really, will force the temporary to be materialized: cpp void F(std::vector<int> v); void G(std::vector<int>&& v); void H(const std::vector<int>& v);

If we take ownership of v in any function, it can be moved at best and copied at worst (last one). This might be obvious to some people! But it stumped me for a while: if we could return a prvalue and chain it along indefinitely before directly initializing it in its final destination, what’s stopping us from doing that with parameters and initializing it in the final destination? Unfortunately for me, the parameter itself is a destination, so there’s no escaping binding something at that level.

We now have built up enough material for me to do my own take on totw/117.

explicit Widget(std::string name) : name_(std::move(name)) {}

//... later
Widget widget(absl::StrCat(bar, baz));

The article describes this as

“With the second version of the Widget constructor, the temporary string is passed into Widget() by value, which you might think would cause the string to be copied, but this is where the magic happens. When the compiler sees a temporary being used to copy-construct an object, the compiler will simply use the same storage for both the temporary and the new object, so that copying from the one to the other is literally free; this is called copy elision.”

This is true with respect to the dating of the article in 2016. However, in C++17 and beyond, prvalues are only materialized until necessary, this is no longer considered copy elision, and more so simply how prvalues work.

absl::StrCat(bar, baz) is a prvalue, so when we pass this to the function it does not need to be initialized yet. However, once we enter the constructor call, it requires that there is an lvalue std::string name, which is going to be initialized directly with the prvalue when we call—giving us one intialization to set up the paraemter, and a move into the member variable. If we do call this with a lvalue, this will cost us one copy into the value of the parameter, and another move.

This “pass by value and move out” style is a good default when implementing ownership-taking ctors or functions, and if you’re certain that the move constructor is the expensive part: we’ll see how we can cut this down to one copy in the lvalue case, and a move in the rvalue case later on.

Aside: copy elision

some smartasses might’ve said: we didn’t need C++17 for these optimizations!

those smartasses are correct. C++ allows a handful of optimizations to change the behavior of a program, and copy elision was the only one for a long time [I can’t find which ones are allowed now but I swear there are].

The two main flavors of this optimizatino are return value optimization and named return value optimization: affectionately termed (N)RVO as a group.

std::vector<int> bigCalc() {
    std::vector<int> local;
    // do stuff with local
    return local;
};

std::vector<int> v = bigCalc();

std::vector<int> bigReturn() {
    // do stuff to determine a size...
    int size = //...
    return std::vector<int>{size};
};

std::vector<int> v = bigReturn();

Pop quiz! How many constructor calls are done above?

It… depends. But probably 1. Just the normal constructor. Why? Because there is no reason we need to return something new, since that local copy will be destroyed anyways. So we can save a good amount of work (a lot of work before move semantics existed!) by allowing for these optimizations. You might see the return by value and be a little scared by the overhead, but any reasonable compiler will not actually return by value. And at worst, the compiler will insert a std::move and use the move constructor: if you insert a std::move manually, then you force the compiler to use the move constructor and disable NRVO!

RVO is covered in the section above: it is exactly the case of returning a prvalue, and in C++17 no longer is an optimization and rather is defined in the standard. But its legacy as an optimization is obvious in the popular-but-unfortunate name “guaranteed copy elision” now given to this behavior. It’s not copy elision! There are no copies to elide! There cannot be a copy! The standard tells us what happens, and it gets directly initialized. We can even delete the copy constructor! End of story.

Unfortunately, NRVO is still an optimization. The paper notes:

“While we believe that reliable NRVO (“named return value optimization”, the second bullet) is an important feature to allow reasoning about performance, the cases where NRVO is possible are subtle and a simple guarantee is difficult to give.”

We may wait a little longer to have “guaranteed NRVO”, but such is life.

forwarding references

will do this eventually. but i’m too lazy

Built with Pollen and Racket