CodeRigor

FREE LESSON

Move semantics and value categories

Know which expressions can be moved from, write correct noexcept move operations, and know when the compiler elides copies for you.

Copying a std::vector with a million elements allocates and copies a million elements. Moving it copies three pointers. Move semantics is the language machinery that lets a type say "if nobody needs the source any more, steal its resources instead of duplicating them". The skill is knowing when an expression may be moved from, writing move operations that are correct and noexcept, and knowing when the compiler removes copies and moves entirely.

1. Value categories: who can be moved from?

Every expression has a type and a value category. Three categories matter:

Category Meaning Examples
lvalue has an identity you can name again later x, v[0], *p, s.name, a function returning T&
prvalue a pure value, not yet an object anywhere 42, x + 1, std::string("hi"), a function returning T
xvalue an object with an identity, marked "expiring" std::move(x), static_cast<T&&>(x), a function returning T&&

lvalues and xvalues together are glvalues (they refer to an object); prvalues and xvalues together are rvalues (they may be moved from). The rule that drives everything:

Every expression has one of three value categories
Two overlapping groupings: glvalues refer to an object, rvalues may be moved from. xvalues — like std::move(x) — are both.
Every expression has one of three value categories
Two overlapping groupings: glvalues refer to an object, rvalues may be moved from. xvalues — like std::move(x) — are both.

Overload resolution sends rvalues to T&& parameters and lvalues to const T& parameters. A move constructor is just a constructor taking T&&.

C++
#include <print>
#include <string>
#include <utility>

void take(const std::string&) { std::println("lvalue overload: copy if you keep it"); }
void take(std::string&&)      { std::println("rvalue overload: free to steal"); }

std::string make() { return "temp"; }

int main() {
    std::string s = "named";
    take(s);              // lvalue
    take(make());         // prvalue
    take(std::move(s));   // xvalue
    take(s + "!");        // prvalue: the result of operator+
}

One surprise: a named rvalue reference is an lvalue. Inside void f(std::string&& p), the expression p has a name, so it's an lvalue. To move from it you must say std::move(p) again. Names are lvalues, always.

2. std::move doesn't move

std::move(x) is a cast to T&& — it turns an lvalue into an xvalue and does nothing else. The move happens later, if and when the result is used to initialise or assign an object and overload resolution picks a move operation.

C++
#include <print>
#include <string>
#include <utility>

int main() {
    std::string a = "hello";
    std::string&& r = std::move(a);   // just a reference; nothing moved
    std::println("a is still '{}'", a);
    std::string b = std::move(a);     // now the move constructor runs
    std::println("b is '{}'", b);
}

Two consequences:

  • std::move on a const object gives const T&&. A move constructor takes T&&, which can't bind to const, so overload resolution falls back to the copy constructor. std::move of a const object silently copies.
  • If a type has no move operations (only copies), std::move copies too. It is a request, not a command.

3. Writing the move operations

A class owning a heap buffer:

C++
#include <cstddef>
#include <print>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t n) : data_(new char[n]{}), size_(n) {}
    ~Buffer() { delete[] data_; }

    Buffer(const Buffer& other) : data_(new char[other.size_]), size_(other.size_) {
        for (std::size_t i = 0; i < size_; ++i) data_[i] = other.data_[i];
    }

    // Move: take the pointer, leave the source empty but valid.
    Buffer(Buffer&& other) noexcept
        : data_(std::exchange(other.data_, nullptr)), size_(std::exchange(other.size_, 0)) {}

    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;                             // release what we own
            data_ = std::exchange(other.data_, nullptr);
            size_ = std::exchange(other.size_, 0);
        }
        return *this;
    }

    Buffer& operator=(const Buffer& other) {
        if (this != &other) {
            Buffer copy(other);                         // may throw; *this untouched
            *this = std::move(copy);                    // noexcept
        }
        return *this;
    }

    std::size_t size() const { return size_; }

private:
    char* data_;
    std::size_t size_;
};

int main() {
    Buffer a(1024);
    Buffer b = std::move(a);
    std::println("a.size() = {}, b.size() = {}", a.size(), b.size());
}
Buffer b = std::move(a);
start / 1
The move constructor copies two words and nulls the source. One buffer, one owner — a's destructor deletes nullptr, a no-op.
Buffer b = std::move(a);
The move constructor copies two words and nulls the source. One buffer, one owner — a's destructor deletes nullptr, a no-op.

Line by line:

  • std::exchange(obj, new_value) sets obj to new_value and returns the old value: "take it and leave this behind" in one expression.
  • The source must end in a state its destructor can handle — here nullptr (delete[] nullptr is a no-op). Forgetting to null the source means two objects own the same buffer: a double free.
  • Move assignment must release what *this already owns first, or it leaks.
  • if (this != &other) guards self-move-assignment (x = std::move(x)). Without it, we'd delete our own buffer and then take the dangling pointer.
  • Copy assignment builds the copy first, then moves it in. If allocation throws, *this is unchanged (the strong exception guarantee), and self-assignment is naturally safe.

4. Moved-from objects

After a move the source is in a valid but unspecified state: its invariants hold, its destructor runs fine, you may assign to it — but don't assume its value. Standard types are more specific:

  • std::unique_ptr, std::shared_ptr: guaranteed null after a move.
  • std::vector, std::string: valid but unspecified. In practice usually empty, but the standard only promises you can use operations without preconditions (size(), clear(), assignment). Calling size() is fine; calling front() is not, because it requires non-empty.

So reading a moved-from std::string's size is fine; dereferencing a moved-from std::unique_ptr is a null dereference — undefined behaviour.

5. Rule of zero, rule of five

If your class declares any of destructor, copy constructor, copy assignment, move constructor, move assignment, think about all five ("rule of five"), because the compiler's implicit versions interact:

  • Declaring a destructor or a copy operation suppresses the implicit move operations. Your class then silently copies whenever it "moves".
  • Declaring a move operation makes the implicit copy operations deleted.

Better: the rule of zero. Hold resources in members that already manage them (std::vector, std::string, std::unique_ptr) and declare none of the five. The implicit versions are correct, noexcept where possible, and memberwise.

C++
#include <memory>
#include <print>
#include <string>
#include <vector>

struct Document {                       // rule of zero: no special members
    std::string title;
    std::vector<std::string> lines;
    std::unique_ptr<int> handle;        // makes Document move-only
};

int main() {
    Document d{"notes", {"a", "b"}, std::make_unique<int>(7)};
    Document e = std::move(d);          // memberwise move, noexcept
    std::println("{} lines, handle={}, old handle null: {}",
                 e.lines.size(), *e.handle, d.handle == nullptr);
}

6. Why noexcept matters: std::vector growth

When a vector reallocates it must move its elements to the new buffer. If a move throws halfway through, some elements are in the new buffer and some in the old — the vector can't offer the strong guarantee. So std::vector uses std::move_if_noexcept: it moves only if the move constructor is noexcept (or the type can't be copied), and copies otherwise.

C++
#include <print>
#include <vector>

struct Noisy {
    Noisy() = default;
    Noisy(const Noisy&) { std::println("copy"); }
    Noisy(Noisy&&) { std::println("move"); }   // not noexcept!
};

int main() {
    std::vector<Noisy> v(1);
    v.reserve(2);   // reallocates the one existing element: copies, not moves
}
How vector moves its elements when it grows
std::move_if_noexcept picks per type: a move that might throw would leave the vector half-moved with no way back, so it copies instead and keeps the strong guarantee.
How vector moves its elements when it grows
std::move_if_noexcept picks per type: a move that might throw would leave the vector half-moved with no way back, so it copies instead and keeps the strong guarantee.

Add noexcept to the move constructor and the same program prints move. Mark move operations noexcept whenever they can't throw — which, for pointer-stealing moves, is always.

7. Copy elision: no copy, no move at all

Since C++17, initialising an object from a prvalue of the same type doesn't create a temporary at all: the prvalue initialises the target directly. This is guaranteed copy elision — no copy or move constructor is called, and the type doesn't even need one.

C++
#include <print>

struct Noisy {
    Noisy() { std::println("construct"); }
    Noisy(const Noisy&) { std::println("copy"); }
    Noisy(Noisy&&) noexcept { std::println("move"); }
};

Noisy make() { return Noisy{}; }   // returns a prvalue

int main() {
    Noisy n = make();              // prints only "construct"
}
Noisy n = make(); — one object, built in place
main reserves n's storage and passes its address; return Noisy{}; constructs straight into it. No temporary, so no copy or move to elide.
Noisy n = make(); — one object, built in place
main reserves n's storage and passes its address; return Noisy{}; constructs straight into it. No temporary, so no copy or move to elide.

Returning a named local (Noisy x; ...; return x;) is different: the compiler may construct x directly in the caller's storage (NRVO), but isn't required to. If it doesn't, the return is treated as an rvalue and the local is moved — never copied (when a move constructor exists).

That's why return std::move(local); is a pessimization: it turns the return expression into an xvalue, which disables NRVO and forces a move where there might have been nothing. Likewise return std::move(Noisy{}); turns a prvalue (guaranteed elision) into an xvalue (a real move). Write return local; and return Noisy{};.

8. Worked example: a move-only handle

Some resources must have exactly one owner — a file descriptor, a socket, a lock. Delete the copy operations and provide moves. C++26 lets = delete carry a reason that appears in the compiler error:

C++
#include <print>
#include <utility>

class Handle {
public:
    explicit Handle(int fd) : fd_(fd) {}
    ~Handle() { if (fd_ >= 0) std::println("closing fd {}", fd_); }

    Handle(const Handle&) = delete("a Handle owns its fd; move it instead");
    Handle& operator=(const Handle&) = delete("a Handle owns its fd; move it instead");

    Handle(Handle&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
    Handle& operator=(Handle&& o) noexcept {
        if (this != &o) {
            reset();
            fd_ = std::exchange(o.fd_, -1);
        }
        return *this;
    }

    void reset() {
        if (fd_ >= 0) std::println("closing fd {}", fd_);
        fd_ = -1;
    }
    int fd() const { return fd_; }

private:
    int fd_;
};

int main() {
    Handle a(3);
    Handle b = std::move(a);       // a.fd() == -1 now
    std::println("a={} b={}", a.fd(), b.fd());
}   // prints "closing fd 3" exactly once

Trying to copy one now fails with your message:

C++
#include <utility>

class Handle {
public:
    explicit Handle(int fd) : fd_(fd) {}
    Handle(const Handle&) = delete("a Handle owns its fd; move it instead");
    Handle(Handle&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
private:
    int fd_;
};

int main() {
    Handle a(3);
    Handle b = a;   // error: use of deleted function ... a Handle owns its fd; move it instead
}

Faded example — your turn

Here's a move-only Lease that holds an int* slot it must return by setting *slot = 0 in the destructor (if non-null). Fill in the move constructor and move assignment. What must the moved-from object look like so its destructor doesn't release the slot a second time?

C++
Lease(Lease&& o) noexcept : slot_(/* ??? */) {}
Lease& operator=(Lease&& o) noexcept {
    if (this != &o) {
        /* ??? */
    }
    return *this;
}
Answer
C++
Lease(Lease&& o) noexcept : slot_(std::exchange(o.slot_, nullptr)) {}
Lease& operator=(Lease&& o) noexcept {
    if (this != &o) {
        release();                                  // give back our own slot first
        slot_ = std::exchange(o.slot_, nullptr);
    }
    return *this;
}

The moved-from Lease holds nullptr, which its destructor ignores.

9. Forwarding references and std::forward (briefly)

In a template, T&& where T is deduced is a forwarding reference: it binds to anything, and T remembers whether the argument was an lvalue (T = U&) or an rvalue (T = U). std::forward<T>(x) re-applies that category: it moves rvalues and leaves lvalues alone. Use std::move on rvalue references, std::forward on forwarding references.

C++
#include <print>
#include <string>
#include <utility>
#include <vector>

template <class T>
void add(std::vector<std::string>& v, T&& s) {
    v.push_back(std::forward<T>(s));   // copies lvalues, moves rvalues
}

int main() {
    std::vector<std::string> v;
    std::string keep = "kept";
    add(v, keep);                      // copy: keep is still usable
    add(v, std::string("moved"));      // move
    std::println("{} {} | keep='{}'", v[0], v[1], keep);
}

Summary

  • lvalue: has a name. prvalue: a pure value. xvalue: std::move(x). Rvalues bind to T&&, and that's how move operations get chosen.
  • std::move is a cast. Moving from a const object copies.
  • Move operations steal resources with std::exchange, leave the source destructible, release the old resource in move assignment, guard self-move, and are noexcept — otherwise std::vector copies instead.
  • Prefer the rule of zero; if you write one special member, think about all five.
  • Prvalue initialisation is guaranteed elision; returning a named local may use NRVO or a move. Never return std::move(local).
  • Move-only types: = delete("reason") the copies (C++26).

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Part 1: rvalue references, std::move and the move special members.

Part 2: forwarding references, std::forward and noexcept moves — how vector decides to move or copy.

Moved-from state, move-only types and the pitfalls of moving const objects.

Value categories and when copy elision makes std::move unnecessary (or harmful).

Forwarding references and perfect forwarding in depth.

Why writing code that never needs std::move (return by value, rely on elision) beats sprinkling moves.

How a seemingly harmless declaration silently disables moves and turns them into copies.

Return value optimisation with trivially copyable types: what the compiler actually emits.