C++ Value Categories and Move Semantics: Trace What Actually Moves

Trace a unique allocation through lvalues, xvalues, prvalues, copy and move constructors, named rvalue references, const, and C++17 copy elision.

KnowledgeGate Team

Exam prep & CS education

Updated 25 Oct 20265 min read

std::move(a) can leave a untouched, yet a later constructor call with the same spelling can transfer its allocation. Syntax alone does not reveal when ownership changes.

Payloads [4, 8, 15] and [2, 3] make overload binding, allocation ownership, and C++17 copy elision visible at each step. For a refresher on references, constructors, or lifetime, start with C++ Tutorial: Complete Learning Path in 12 Weeks.

1. C++ value categories: put every expression on one map

Every C++ expression is an lvalue, xvalue, or prvalue. Lvalues and xvalues are glvalues because they identify objects or functions; prvalues and xvalues are rvalues because they can bind to rvalue references. An lvalue is not merely something left of =.

The expression a is an lvalue, std::move(a) is an xvalue, and make_buffer() is a prvalue. In Buffer&& ref = std::move(a), the initializer is an xvalue, but the named expression ref is an lvalue.

std::move is a cast to an xvalue. Alone, it invokes no constructor and transfers nothing. In Coding & DSA, classify the expression first, then inspect the selected function.

Value-category map: glvalue covers lvalue (a, named ref) and xvalue (std::move(a)); rvalue covers that xvalue and prvalue make_buffer().

2. Overload selection can happen without any move

Start with this exact overload set. Each function only prints which reference bound to the argument.

cpp
#include <iostream>

struct Buffer;

void tag(Buffer&) { std::cout << "lvalue\n"; }
void tag(const Buffer&) { std::cout << "const reference\n"; }
void tag(Buffer&&) { std::cout << "rvalue\n"; }

Construct Buffer a{101, {4, 8, 15}};. Name its allocation P1, giving a={101,3,P1->[4,8,15]}.

It logs construct 101. P1 names the separate array uniquely owned by data.

tag(a) receives an lvalue, so the Buffer& overload prints lvalue. Next, tag(std::move(a)) receives an xvalue, so the Buffer&& overload prints rvalue. Reference binding explains both choices.

After both calls, the state is still a={101,3,P1->[4,8,15]}. Nothing was copied or transferred: the rvalue-reference overload only observed the argument. Selecting Buffer&& does not prove a move occurred.

Diagnose in order: classify the expression, choose the overload, then read its body.

3. The move constructor is where ownership changes

Buffer owns one allocation through std::unique_ptr<int[]>. The copy constructor allocates fresh storage. The move constructor takes the existing storage and deliberately resets the source fields.

cpp
#include <algorithm>
#include <cstddef>
#include <initializer_list>
#include <iostream>
#include <memory>
#include <utility>

struct Buffer {
    int resource_id;
    std::size_t size;
    std::unique_ptr<int[]> data;

    Buffer(int id, std::initializer_list<int> values)
        : resource_id(id), size(values.size()),
          data(size ? std::make_unique<int[]>(size) : nullptr) {
        if (size) std::copy(values.begin(), values.end(), data.get());
        std::cout << "construct " << resource_id << '\n';
    }

    Buffer(const Buffer& other)
        : resource_id(other.resource_id), size(other.size),
          data(size ? std::make_unique<int[]>(size) : nullptr) {
        if (size) std::copy_n(other.data.get(), size, data.get());
        std::cout << "copy " << resource_id << '\n';
    }

    Buffer(Buffer&& other) noexcept
        : resource_id(std::exchange(other.resource_id, -1)),
          size(std::exchange(other.size, 0)),
          data(std::move(other.data)) {
        std::cout << "move " << resource_id << '\n';
    }
};

Continue with Buffer b{std::move(a)};. The xvalue selects the move constructor, which exchanges resource_id 101 and size 3 out of a, then moves its std::unique_ptr. P1 passes to b; the source pointer becomes null.

std::exchange(other.resource_id, -1) returns 101 for b and writes -1 into a; the next exchange returns 3 and writes 0. Moving other.data gives b.data the original pointer and leaves a.data null. No elements are copied and no second array is allocated.

The exact post-state is a={-1,0,null} and b={101,3,P1->[4,8,15]}. The log adds move 101. This state change comes from the constructor body and member initialisers, not from std::move alone.

Overload selection enters the constructor; its implementation performs the transfer. Another Buffer&& function could change nothing, as tag did.

The C++ Move Semantics Tutorial: Trace Ownership with Runnable Examples follows a raw-pointer Buffer through copy and move assignment, vector relocation, and noexcept. The P1 diagnostic separates expression category from overload choice before checking whether the selected body transfers ownership.

Four-stage Buffer timeline: a keeps P1 through tag(std::move(a)), b takes over P1 after the move, and c gets a fresh P2 from the copy.

4. Copy, move, and C++17 copy elision have different traces

Run Buffer c{b};. The lvalue b selects the copy constructor and logs copy 101. The states become b={101,3,P1->[4,8,15]} and c={101,3,P2->[4,8,15]}. The values match, but P1 and P2 are distinct allocations.

Now add:

cpp
Buffer make_buffer() {
    return Buffer{202, {2, 3}};
}

Buffer d = make_buffer();

In C++17 and later, the returned prvalue initialises d directly. The exact state is d={202,2,P3->[2,3]}, and the only new log is construct 202. No copy 202 or move 202 occurs: this is guaranteed copy elision.

Operation

Allocation result

Source result

Copy Buffer c{b}

Creates P2 with the same values

b keeps P1

Move Buffer b{std::move(a)}

Keeps P1 and changes its owner

a becomes {-1,0,null}

C++17 elision

Constructs P3 directly as d

No intermediate source object

5. Named rvalue references and const change the selected path

Consider Buffer&& ref = std::move(b); tag(ref); tag(std::move(ref));. Binding ref transfers nothing; it names b, which still owns P1. The expression ref is an lvalue, so the first call prints lvalue; std::move(ref) is an xvalue, so the second prints rvalue. Since tag consumes nothing, b, ref, and P1 remain unchanged.

For const Buffer frozen{303, {9}};, tag(std::move(frozen)); passes a const Buffer&& xvalue. It cannot bind to non-const Buffer&&, so const Buffer& prints const reference.

Likewise, with only Buffer(const Buffer&) and Buffer(Buffer&&), Buffer e{std::move(frozen)}; copies because the non-const move overload cannot take ownership. Its allocation is independent of frozen. Predict bindings from both category and full type, including const.

6. Move-semantics traps: state what is guaranteed and what is not

Tempting claim

What goes wrong

Correct rule

std::move moves

A cast performs no transfer

It produces an xvalue

An rvalue-reference overload means a move occurred

The callee may only observe

Inspect the function body

Every moved-from object is empty

The state depends on the type

Use its documented guarantees

Moving is always faster

A type may copy or do work while moving

Inspect the operations

Our a is predictably {-1,0,null} because this constructor defines that state and the moved std::unique_ptr becomes null. Do not generalise those values. After surrendering ownership, use the source only within its documented moved-from contract.

7. How interviews and exam-style questions test value categories

Typical checks classify a, std::move(a), make_buffer(), and named ref; predict tag; count logs; draw P1 and P2; test const; or identify guaranteed elision.

Answer this mini-trace first: after construction and both tag calls, where does [4,8,15] live? It is still in P1, owned by a. Only Buffer b{std::move(a)} makes P1 belong to b.

Why does tag(ref) print lvalue although ref is declared Buffer&&? A named variable expression is an lvalue. Keep declaration type, expression category, overload, and callee behaviour separate.

8. C++ move semantics: the short version and next step

For each statement, ask: What is the expression category? Which overload binds? Does it copy or move? Who owns P1 afterward? Reproduce the exact snapshots for a, b, c, and d; explain why tag(std::move(a)) leaves P1 in place but Buffer b{std::move(a)} transfers it.

Use C++ Programming for focused language concepts, or Coding For Placements for the broader C, C++, Java, and Python practice route.