Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

C++ Value Categories and Reference Overload Resolution

Hello! In the previous lesson, you implemented copy and move operations for a resource-owning buffer. The remaining question is how the compiler chooses between them. A move constructor may exist, yet an apparently “movable” argument can still select a copy constructor.

This lesson gives you a way to classify an expression and predict which reference overload it selects. That skill is useful when reviewing ownership code and when a performance-sensitive call unexpectedly copies an object. Allow about 40 minutes, including the video and reading.


Value category describes an expression, not an object

Consider a string named message. The object has a type, std::string; the expression message has a value category, lvalue. If you write std::move(message), you still refer to the same object, but the new expression has a different category: xvalue. No transfer has happened merely because you wrote std::move.

Modern C++ has three fundamental expression categories:

Category Useful interpretation Examples
lvalue Identifies an object or function without marking it as expiring message, *ptr, a function call returning T&
xvalue Identifies an object whose resources may be reused std::move(message), a function call returning T&&
prvalue Computes a value 42, a + b for integers, a function call returning T by value

The broader terms in the diagram group these categories. Glvalues comprise lvalues and xvalues: they identify something. Rvalues comprise xvalues and prvalues. Thus an xvalue belongs to both broader groups; “glvalue versus rvalue” is not an either-or choice.

The five labels group three fundamental expression categories: lvalue and xvalue are glvalues; xvalue and prvalue are rvalues. Xvalue is the overlap.

The old mnemonic “an lvalue can go on the left of =” is insufficient. A const object is still an lvalue even though you cannot assign to it. Nor does “has an address” define the categories: a temporary can be materialized as an object when a reference binds to it. Classify the expression as written, rather than guessing where the compiler will store its result.

Watch Ben Saks’s Back to Basics: Understanding Value Categories on CppCon. The short first selection shows why const breaks the old assignment mnemonic. The later selection connects reference binding, moving, and the complete category hierarchy. The speaker develops some rules historically; use the diagram above as your final C++20 model.

Back to Basics: Understanding Value Categories - Ben Saks - CppCon 2019

Watch Ben Saks’s Back to Basics: Understanding Value Categories (CppCon) to connect the category names to copy and move behavior.

Watch const lvalues for an object that is an lvalue but cannot be assigned to. Then watch moves and categories: focus on the copy-versus-move assignment example, why a named rvalue-reference parameter acts as an lvalue, what std::move changes, and the hierarchy shown at the end.

A few classifications are worth keeping close at hand:

int n = 3;
const int fixed = 4;
int* ptr = &n;

n;                 // lvalue
fixed;             // lvalue, despite const
*ptr;              // lvalue, despite having no variable name of its own
n + fixed;         // prvalue
std::move(n);      // xvalue
"feed";            // lvalue: a string literal is an array

These comments classify each indicated expression itself. Using n as an operand of addition may involve an implicit lvalue-to-rvalue conversion to read its value; that does not make the original expression n an xvalue or prvalue.


Reference binding narrows the candidates

Before choosing the best overload, the compiler rules out overloads whose references cannot bind to the argument. For a type T, the core binding rules are:

Parameter Lvalue T Lvalue const T Xvalue or prvalue T Xvalue or prvalue const T
T& Yes No No No
const T& Yes Yes Yes Yes
T&& No No Yes No
const T&& No No Yes Yes

Here the argument and parameter have the same underlying type. Conversions between different types can add temporary objects and more overload-resolution considerations. Notice particularly that const T& accepts rvalues, while T&& does not accept a const rvalue: a move operation generally needs permission to modify its source.

Read the worked overload example in LearnCpp’s R-value references. It also isolates a distinction that matters whenever a move operation passes its parameter to another function: the type of a reference variable does not determine the category of an expression naming it.

22.2 — R-value references – Learn C++

Read LearnCpp’s R-value references for a compact reference-binding example and the named-reference pitfall.

In “R-value references as function parameters,” start at the sentence “R-value references are more often used as function parameters.” Read the overload examples through “Rvalue reference variables are lvalues,” stopping at the sentence “The type of an object and its value category are independent.” Focus on why the literal selects int&& but a named int&& variable does not. You can stop before “Returning an r-value reference” and the quiz.

Binding is not itself copying or moving. Calling a function whose parameter is T&& merely binds a reference. A copy or move occurs only if code constructs or assigns an object using a suitable operation.


Predict the selected overload

Here is a small overload set. Each function reports which parameter received the argument:

#include <iostream>
#include <string>
#include <utility>

void accept(std::string&)       { std::cout << "mutable lvalue\n"; }
void accept(const std::string&) { std::cout << "const lvalue\n"; }
void accept(std::string&&)      { std::cout << "mutable rvalue\n"; }

int main() {
    std::string live{"bid"};
    const std::string frozen{"ask"};
    std::string&& alias = std::string{"trade"};

    accept(live);                    // mutable lvalue
    accept(frozen);                  // const lvalue
    accept(std::string{"fresh"});    // mutable rvalue
    accept(std::move(live));         // mutable rvalue
    accept(alias);                   // mutable lvalue
    accept(std::move(frozen));       // const lvalue
}

A reliable prediction takes three passes:

  1. Classify the argument expression. live, frozen, and alias are lvalues. Constructing std::string{"fresh"} produces a prvalue. Both std::move(...) expressions are xvalues.
  2. Keep its const qualification. std::move(frozen) is a const xvalue. The cast changes the value category; it does not remove const.
  3. Find viable bindings, then the best match. For live, both std::string& and const std::string& can bind, but the non-const reference is the better match. For a mutable prvalue or xvalue, std::string&& is preferred over const std::string&. For std::move(frozen), std::string&& cannot bind because the source is const, so const std::string& wins.

The alias call is the common surprise. The declaration gives alias the type std::string&&, but the expression alias is an lvalue: it names an object that code can continue to use. To present that object as an xvalue again, write std::move(alias).

This explains a detail of the previous lesson’s move constructor:

ByteBuffer(ByteBuffer&& other) noexcept
    : data_(std::exchange(other.data_, nullptr)),
      size_(std::exchange(other.size_, 0)) {}

Inside the constructor, other is an lvalue expression. The code transfers its members explicitly with std::exchange; it does not rely on the parameter’s && spelling to transfer anything automatically.

Also distinguish selecting an rvalue-reference overload from performing a move. In the accept example, no overload transfers resources. Conversely, constructing a new string from the const object illustrates why std::move cannot force a move:

std::string copy = std::move(frozen); // Copies: std::string&& cannot bind to const.

For std::string, the copy constructor taking const std::string& remains available. A const T&& overload could distinguish const rvalues, but it usually cannot implement resource-stealing move semantics because it cannot modify the source.


Check return expressions and diagnose surprises

Function return types give another compact way to classify call expressions:

std::string  make_value();
std::string& get_existing();
std::string&& get_expiring();

// make_value()   is a prvalue.
// get_existing() is an lvalue.
// get_expiring() is an xvalue.

The function call’s category follows how the function returns, not whether the call expression has a name. Be careful with a real function returning a reference: the referenced object must still be alive. Returning an rvalue reference to a local object would dangle.

When inspecting unfamiliar code, decltype((expression)) can confirm the category at compile time. With the extra parentheses, it yields an lvalue-reference type for an lvalue expression, an rvalue-reference type for an xvalue, and a non-reference type for a prvalue:

#include <string>
#include <type_traits>
#include <utility>

std::string s;

static_assert(std::is_same_v<decltype((s)), std::string&>);
static_assert(std::is_same_v<decltype((std::move(s))), std::string&&>);
static_assert(std::is_same_v<decltype((std::string{"x"})), std::string>);

The parentheses matter: decltype(s) on an unparenthesized variable name reports its declared type, not this category-based result. This technique is useful for verifying a prediction, but the better code-review habit is to first ask what the expression identifies or computes, whether it is marked as expiring, and whether it is const.

Finally, do not infer a runtime copy solely from a prvalue’s ability to bind to T&&. For example, initializing an object directly from a same-type prvalue can use guaranteed copy elision in C++20. Overload selection, object construction, and actual resource transfer are related but distinct questions.


Conclusion

Value categories belong to expressions: lvalue, xvalue, or prvalue. For reference overloads, classify the argument, retain its const qualification, eliminate references that cannot bind, and then choose the best viable match. The two traps to remember are that a named T&& variable is an lvalue expression, and std::move produces an xvalue without transferring anything by itself.

The next lesson applies those rules to forwarding references and std::forward: a wrapper must pass an argument onward without accidentally turning every named parameter into an lvalue at the next call.

Can't find a good explanation? Sign up and we'll make it for you

Sign up