C++ Value Categories and Move Semantics

Last update: 2021-01-25 02:14:28 +0800 +0800
Available translations: 日本語 / 中文(简体) / 中文(繁體)

Move semantics are overload selection and type-defined state transfer, not an automatic transfer of ownership. Begin with an expression’s value category, then choose a parameter type that describes ownership and lifetime.

Expression Categories #

Every non-void expression is exactly one of these categories:

  • An lvalue identifies an object, function, or bit-field.
  • An xvalue identifies an object whose resources may be reused.
  • A prvalue computes a value or initializes an object.

Lvalues and xvalues are glvalues; prvalues and xvalues are rvalues. A named variable is an lvalue expression even when it has type T&&.

cpp
int& counter();
int&& cache_slot();

counter() = 42;              // The call is an lvalue.
int&& slot = cache_slot();
consume(slot);               // `slot` is an lvalue expression.
consume(std::move(slot));    // Casts it to an xvalue.

A call returning T is a prvalue, one returning T& is an lvalue, and one returning T&& is an xvalue. std::move performs an xvalue cast; it neither moves data nor ends the source object’s lifetime.

Move Construction Is Overload Selection #

Reference binding provides the relevant distinction:

  • T& binds compatible lvalues.
  • const T& binds lvalues, xvalues, and prvalues.
  • T&& binds xvalues and prvalues.
cpp
std::string source = "payload";
std::string destination = std::move(source); // Selects a move constructor when viable.

The selected constructor defines the transfer. It might transfer an allocation, copy a small representation, or have an effect indistinguishable from copying. Returning a local by value should normally use return local;: copy elision may construct the result directly, and when elision does not apply an eligible local can be treated as an xvalue for overload selection. return std::move(local); can inhibit named return value optimization.

Forwarding References Preserve the Caller’s Category #

A T&& in a deduced template parameter is a forwarding reference. For an lvalue argument, T deduces as U&; for an rvalue, it deduces as U. Reference collapsing reduces U& && to U&, so std::forward<T> can reproduce the original category.

cpp
template<class T>
void relay(T&& value) {
    consume(std::forward<T>(value));
}

std::move(value) would turn both cases into xvalues and can unexpectedly move from an lvalue caller. A T&& that does not use deduction—for example, a member of a class template whose T is already fixed—is an ordinary rvalue reference, not a forwarding reference.

Parameter Design Starts with Ownership #

Use the parameter form that describes the callee’s contract:

FormContract
const T&Borrow for read-only use during the call.
T&Borrow and mutate during the call.
TTake an independent value; callers copy or move into it.
T&&Require an rvalue that the callee may consume; ownership is not implied.
std::unique_ptr<T>Transfer exclusive ownership.
std::shared_ptr<T>Share ownership when the callee retains a share.
T*Borrow, with nullability made explicit by the API.

A constructor that stores an independent value can take it by value and move from its parameter:

cpp
class Message {
public:
    explicit Message(std::string text) : text_{std::move(text)} {}
private:
    std::string text_;
};

An lvalue caller performs one copy into the parameter; an rvalue caller can move into it. Passing by value is inappropriate for a query or for an object that the callee merely borrows.

Do not use FILE*&& or another raw-pointer rvalue reference to imply ownership. Moving a raw pointer copies its address; both names still point at the same resource. Use an ownership-bearing wrapper, such as std::unique_ptr<T, Deleter>, when the call transfers destruction responsibility. A function that only uses the object owned by a smart pointer should take T&, const T&, T*, or const T* according to access and nullability; it need not accept the smart-pointer handle.

Moved-from Objects Remain Alive #

After a standard-library object is moved from, it has a valid but unspecified value unless a specification says more. Destruction, assignment, and operations whose preconditions accept that state are valid. Do not assume a moved-from std::vector is empty or retains capacity.

cpp
std::vector<int> buffer = read_row();
rows.push_back(std::move(buffer));
buffer.clear(); // Valid, but capacity reuse is not guaranteed.

A borrow must not outlive its owner. Moving an owning unique_ptr destroys neither the pointee nor all pre-existing borrows, but later destroying or resetting the new owner can make those borrows dangle. Move operations solve representation transfer; they do not remove the need to state lifetimes.

Comments

Read or comment on GitHub (sign-in required to comment)