Skip to content
C++
C++11Intermediate

Move Semantics

Before C++11, passing or returning a std::vector by value always triggered a full deep copy — every element duplicated, every byte allocated twice. Move semantics changed this: when the source object is a temporary that is about to be destroyed anyway, C++ can steal its resources instead of copying them. Understanding this feature well requires understanding a few underlying concepts: value categories, rvalue references, and what std::move actually is.

The problem: copies that don't need to be copies

Imagine a function that creates a std::stringand returns it. Before C++11 (and without Return Value Optimization), this involved copying the string from the function's stack frame into the caller's variable — allocating new memory, copying every character, then immediately destroying the original. This is wasteful: the original string was about to die, and its memory could have been handed over directly. Move semantics make this possible by distinguishing "things I can take from" from "things I must copy."

// Before C++11: this return triggers a deep copy of potentially MBs of data
std::vector<int> make_data() {
    std::vector<int> result(1'000'000);
    // fill result...
    return result;  // C++98: copy. C++11: RVO or move — zero copy.
}

// With a move constructor, std::vector "steals" the heap buffer:
// Before move: result owns {ptr → heap[1M ints], size=1M, cap=1M}
// After  move: caller owns the same pointer; result is left empty
// Cost: copying 3 pointers/integers instead of 1 million integers

Value categories: lvalue, rvalue, xvalue

Every expression in C++ has a value category that tells the compiler two things: does the expression have a persistent identity (an address you can take), and is it safe to steal resources from? C++11 introduced a three-way split:

CategoryHas identity?Movable?Examples
lvalueYesNo (not by default)Named variables, *ptr, a[i], function returning T&
prvalueNoYesLiterals (42, true), temporaries, function returning T by value
xvalueYesYesstd::move(x), function returning T&&

An xvalue ("expiring value") is the key invention. It is an lvalue that has been explicitly marked as safe to move from — it has an identity, but the programmer has declared that they no longer care about its contents. This is exactly what std::move produces. The two composite categories you'll also see: glvalue = lvalue or xvalue (anything with identity); rvalue = prvalue or xvalue (anything movable).

Rvalue references and the move constructor

A rvalue reference (T&&) is a reference that binds only to rvalues — it is the mechanism through which a move constructor or move assignment operator receives the "donate your resources" request. When the compiler sees that the source of a construction or assignment is an rvalue, it will prefer the T&& overload over the const T& overload if one exists.

class Buffer {
    char*  data_;
    size_t size_;

public:
    // Copy constructor: deep copy — O(n)
    Buffer(const Buffer& other)
        : data_(new char[other.size_])
        , size_(other.size_) {
        std::memcpy(data_, other.data_, size_);
    }

    // Move constructor: steal the pointer — O(1)
    Buffer(Buffer&& other) noexcept
        : data_(other.data_)     // take ownership
        , size_(other.size_) {
        other.data_ = nullptr;   // leave source in valid but empty state
        other.size_ = 0;
    }

    // Move assignment: steal, release old resources
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;          // free what we currently own
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    ~Buffer() { delete[] data_; }
};

The critical discipline: after a move, the source object must be left in a valid but unspecified state. The standard library guarantees this — you can safely reassign or destroy a moved-from object, but you cannot assume any particular value. Setting the pointer to nullptr and size to 0 satisfies this: the destructor's delete[] nullptr is a no-op.

std::move — what it is and what it isn't

The name is misleading. std::move does not move anything. It is a cast — it takes an object and returns an rvalue reference to it. That's the entire implementation. No runtime code is generated. The move only happens later, in the move constructor or move assignment operator that receives the rvalue reference. As Scott Meyers puts it: "A better name might have been rvalue_cast."

// std::move's full implementation (simplified from C++14):
template<typename T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(t);
}
// It removes any reference, then adds &&.
// If T is int&, result is int&&. If T is int, result is int&&.
// At runtime: generates no code whatsoever.
// What std::move does in practice:
std::vector<int> a = {1, 2, 3, 4, 5};

// Without move: deep copy — b gets its own copy of the heap array
std::vector<int> b = a;          // b is an independent copy; a unchanged

// With move: pointer theft — b takes a's heap array, a is left empty
std::vector<int> c = std::move(a); // c now has {1,2,3,4,5}; a is now {}
// a is still valid — you can call a.push_back(6) — but its contents are gone

The const trap

Moving a const object silently falls back to a copy. The move constructor takes a non-const rvalue reference (T&&) because it needs to modify the source. But std::move(const T) produces a const T&&, which cannot bind to T&& — so the copy constructor (const T&) is chosen instead. If you see std::move applied to a constobject, it's a no-op.

const std::string s = "hello";
std::string t = std::move(s);  // COPIES, not moves — s is const!
// Lesson: don't declare objects const if you intend to move from them

Forwarding references — when T&& isn't an rvalue reference

Here is one of C++'s most surprising facts: T&& in source code has two entirely different meanings depending on context. When T is a deduced type parameter (or when using auto&&), the && is a forwarding reference — it can bind to either lvalues or rvalues, taking on the value category of whatever was passed to it. When there is no type deduction, it is a plain rvalue reference that binds only to rvalues.

void f(Widget&& w);           // rvalue reference: binds ONLY to rvalues

template<typename T>
void g(T&& param);            // forwarding reference: binds to ANYTHING
                              // (lvalue or rvalue, const or non-const)

Widget w;
g(w);                         // lvalue passed: T deduced as Widget&
g(std::move(w));              // rvalue passed: T deduced as Widget
g(Widget{});                  // prvalue passed: T deduced as Widget

// auto&& is also a forwarding reference:
auto&& result = some_function();  // binds to whatever some_function() returns

The reason forwarding references work is reference collapsing: the compiler silently resolves the "reference to a reference" that arises during template instantiation. The rule is simple — if either reference involved is an lvalue reference (&), the result is an lvalue reference. Only && + && collapses to &&.

// Reference collapsing rules:
// T = Widget&   → Widget&  && → Widget&   (lvalue ref wins)
// T = Widget&&  → Widget&& && → Widget&&  (two rvalue refs → rvalue ref)
// T = Widget    → Widget      && → Widget&&

// This is how forwarding references encode lvalue/rvalue in T:
// Pass lvalue: T = Widget&  → param type = Widget&  (lvalue reference)
// Pass rvalue: T = Widget   → param type = Widget&& (rvalue reference)

std::forward — preserving value category through layers

Inside a function that takes a forwarding reference, the parameter itself is always an lvalue — it has a name and an address. If you just pass it along to another function, it will always be passed as an lvalue, losing the rvalue nature of the original argument. std::forward<T>(param) restores this: it is a conditional cast that casts param to an rvalue only if the original argument was an rvalue. Use std::move on rvalue references; use std::forward on forwarding references.

void process(Widget& w)  { /* lvalue overload */ }
void process(Widget&& w) { /* rvalue overload */ }

template<typename T>
void relay(T&& arg) {
    // Without forward: always calls lvalue overload (param is always lvalue)
    process(arg);                    // always lvalue — wrong!

    // With forward: calls the correct overload based on original value category
    process(std::forward<T>(arg));   // lvalue if arg was lvalue; rvalue if arg was rvalue
}

Widget w;
relay(w);               // calls process(Widget&)
relay(std::move(w));    // calls process(Widget&&)
relay(Widget{});        // calls process(Widget&&)
// The rule: apply std::move / std::forward on the LAST USE
// If you use the value multiple times, don't move/forward until the final use

template<typename T>
void setSignText(T&& text) {
    sign.setText(text);                       // first use: don't move yet
    auto now = std::chrono::steady_clock::now();
    history.add(now, std::forward<T>(text));  // last use: forward
}

When move operations aren't cheap

Move semantics are only faster than copying when the type actually has heap-allocated resources that can be stolen. Several common cases where move is not O(1):

std::array<T, N>

O(N) — same as copy

Elements live directly inside the array object — no heap pointer to steal. Moving an std::array copies all N elements, exactly like a copy.

std::string (small strings)

O(N) for short strings, O(1) for long strings

Most implementations use Small String Optimization (SSO): strings ≤ 15 characters are stored inline in the string object itself, not on the heap. Moving an SSO string copies the inline buffer — it can't steal a pointer that doesn't exist.

Types with no move constructor

O(N) — falls back to copy

If a class declares a copy constructor or destructor without declaring move operations, the compiler does NOT generate move operations. std::move on such a type falls back silently to the copy constructor.

Types with noexcept-unconstrained moves

O(N) copy instead of O(1) move if noexcept is missing

std::vector::push_back will only use the move constructor if it is noexcept — otherwise it copies (to maintain the strong exception guarantee). Always mark move operations noexcept.

Don't std::move on return values

A common mistake is "helping" the compiler by applying std::move to a local variable being returned. This actually prevents the compiler from applying Return Value Optimization (RVO/NRVO), which is better than a move — it constructs the object directly in the caller's memory, skipping any construction of a temporary. The C++ Standard explicitly requires that when RVO conditions are met but the compiler chooses not to elide, it must treat the return as a move anyway. So writing return std::move(local) buys nothing and may cost an RVO opportunity.

Widget makeWidget() {
    Widget w;
    // ... configure w ...
    return w;             // CORRECT: compiler applies NRVO or implicit move
}

Widget makeWidget() {
    Widget w;
    return std::move(w);  // WRONG: prevents NRVO; forces a move instead of elision
}

// The only time return std::move makes sense: returning a parameter or member
// (not a local variable), where RVO is impossible.
Widget process(Widget w) {  // parameter, not local — RVO doesn't apply
    // ...
    return std::move(w);    // OK: this is the last use, so move is appropriate
}

Key rules to remember

  • →std::move is a cast, not a move — the actual move happens in the move constructor/assignment
  • →Apply std::move to rvalue references; apply std::forward to forwarding references
  • →Apply both only on the final use of the value in a function body
  • →Never apply std::move to a local variable in a return statement — it disables RVO
  • →Don't move from const objects — the compiler silently copies instead
  • →Mark move constructors and move assignment noexcept — std::vector requires this to avoid copying on reallocation
  • →Move is only O(1) for types with heap resources — std::array and SSO strings are O(N)
Sign in to track progress