Skip to content

Chapter 6 — The Rule of Five and Move Semantics

Chapter 6 — The Rule of Five and Move Semantics

The heart of resource-owning classes — and the explanation of what std::move actually does.

Setup: the special member functions

The compiler auto-generates up to five functions for every class. The generated versions copy/move each member field:

class Buffer {
public:
    ~Buffer();                          // 1. destructor
    Buffer(const Buffer&);              // 2. copy constructor
    Buffer& operator=(const Buffer&);   // 3. copy assignment
    Buffer(Buffer&&);                   // 4. move constructor   (C++11)
    Buffer& operator=(Buffer&&);        // 5. move assignment    (C++11)
};

The Rule of Zero (the modern ideal)

Write none of the five. Compose your class out of members that manage themselves (std::string, std::vector, unique_ptr) and the compiler-generated versions are automatically correct:

class Document {
    std::string title_;
    std::vector<Page> pages_;
    std::unique_ptr<Renderer> renderer_;
    // NOTHING to write. Copy, move, destruction - all correct for free.
    // (copy is deleted because of unique_ptr - which is correct too)
};

The Rule of Five

If you must write any one of the five (usually because you hold a raw resource), you almost certainly need to write — or explicitly delete — all five. Destructor without copy operations = the double-free bug. Copy without move = silent performance loss everywhere.

What "move" actually means

Copying a resource-owning object duplicates the resource — expensive. Moving means stealing: the new object takes the guts (the pointer), the old object is left empty but valid.

std::vector<int> a = MakeMillion();
std::vector<int> b = a;             // COPY: allocate + copy 1M ints
std::vector<int> c = std::move(a);  // MOVE: c takes a's pointer.
                                    // ~3 pointer assignments. a now empty.

The big reveal: std::move moves nothing. It is just a cast — it marks a value as "you may steal from this", making the compiler select the move overload instead of the copy one. The stealing happens inside the move constructor. After moving from a variable, don't use it except to assign or destroy it.

Try it (30 seconds). Write a bare std::move(a); statement — no receiver — and predict a.size() on the next line. Nothing moved: the cast did everything it ever does on its own, which is nothing. (Newer standard libraries even warn that you discarded the result.)

Rvalue references — the && syntax

Buffer&& means "reference to something I'm allowed to steal from": a temporary, or something explicitly marked with std::move.

void Take(const Buffer& b);   // called for normal variables (copy)
void Take(Buffer&& b);        // called for temporaries / std::move'd things

Buffer x;
Take(x);              // first overload
Take(std::move(x));   // second - you granted permission to steal
Take(MakeBuffer());   // second - temporaries are fair game automatically

That last line is the one to keep: a function's result is a temporary, so it selects the stealing overload on its own — you never write std::move around a call, and nothing is copied into Take.

Returning containers by value is cheap for a separate reason, and the two are worth holding apart because they are routinely conflated. Since C++17, a function that returns a temporary outright — return Buffer(n); — gets mandatory elision: the object is built directly in the caller's storage, and no copy or move constructor is called or even required to exist. Return a named local instead and you are back to the optional flavour, NRVO, with an implicit move as the fallback (Chapter 14 watches both happen).

Value categories in one table

Every expression in C++ has a value category, and the Take overloads above are chosen by it. C# never asked you: every expression there is a reference or a copy, and the runtime keeps the object alive either way. Here the category decides whether an object may be stolen from, and three words carry the whole story:

  • An lvalue has a name, or an address you could keep — x, v[3], *p, a function returning a reference. Nobody may steal from it, because somebody may still be looking.
  • A prvalue is a temporary being made — Buffer(n), MakeBuffer(), a + b. Since C++17 it is not even an object yet, which is what mandatory elision means.
  • An xvalue is an lvalue that has been marked expiring — the result of std::move(x). Same object, permission granted.

(The two groupings cppreference uses: glvalue is lvalue-or-xvalue, "has identity"; rvalue is prvalue-or-xvalue, "may be moved from".)

What binds to what — the table the chapter rests on, written for a concrete type (Buffer, not a deduced T; that one is the coda below):

Parameter an lvalue a temporary, or std::move(x) a const lvalue a const temporary, or std::move(const_x)
Buffer& yes no no no
const Buffer& yes yes — and a temporary lives as long as the reference yes yes
Buffer&& no yes no no
const Buffer&& no yes no yes — and nobody writes one, so this column lands in the copy constructor

Four traps fall straight out of that table, and every one of them compiles clean:

  1. A named rvalue reference is an lvalue. Inside Tracer(Tracer&& other) (Chapter 14), other has a name — so name_(other.name_) copies the string, and the move constructor must write name_(std::move(other.name_)) or it is Finding 1 of Chapter 25's copy-shaped move. Every move operation in this book spells it that way for this reason. (A raw pointer member, like the Buffer's data_ below, is the exception that proves it: copying a pointer is the steal, and what the move must add is nulling the source.)
  2. std::move on a const object copies. It casts to const Buffer&&, the last column: no move constructor takes that, the copy constructor does, and clang under this book's flags says nothing about it. exercises/choosing/ prices it:
void MovingFromAConstObjectCopies() {
    const Counted keep("const");
    ResetTally();
    Counted taken = std::move(keep);     // reads as a move, is a copy
    CHECK(Tally().copies == 1);
    CHECK(Tally().moves  == 0);
    CHECK(keep.Payload().size() > 0);    // nothing was taken from it
    (void)taken;
}
  1. return std::move(local); costs the move that elision would have removed. A plain return local; is eligible for NRVO (Chapter 14 watches it happen); the cast turns the operand into an xvalue the compiler may not elide. -Wall on clang and GCC names it, -Wpessimizing-move, and the lab measures exactly one move on both of its build passes:
Counted MakeNamedMoved() {
    Counted local("named");
    return std::move(local);             // the pessimizing move
}
void ReturnStdMoveCostsTheMoveElisionRemoved() {
    ResetTally();
    Counted c = MakeNamedMoved();
    CHECK(Tally().copies == 0);
    CHECK(Tally().moves  == 1);          // always one: NRVO was cast away
    (void)c;
}
  1. const T& extends a temporary's life — through a member, not through a call. const std::string& s = MakeWidget().name; keeps the whole Widget alive for as long as s exists; const std::string& s = MakeWidget().Name(); binds to a reference returned by a function, the Widget dies at the semicolon, and AddressSanitizer reports a stack-use-after-scope on the next read. Chapter 10's dangling string_view is the same rule with a view in place of the reference.

Key principle: "A named rvalue reference is an lvalue: std::move inside every move operation, never around a returned local, and never on a const."

And one && that is none of those rows. Everything above is && on a concrete type, and it means what it says: this binds only to things you may steal from. In one narrow place the same characters are a different feature — a function template's own parameter, with T deduced from that very argument. template <class T> void f(T&& x) is a forwarding reference: it binds to lvalues and rvalues alike, T is deduced differently for each, and the way you pass it onward is std::forward<T>(x), not std::move(x) — which is how std::make_unique and emplace_back hand your arguments through untouched. Narrow means narrow: a member of a class template, a const T&&, a std::vector<T>&&, or an explicit f<int>(x) are plain rvalue references again, exactly like everything above. You will meet the feature long before you have reason to write it. The thing to carry out of this chapter is only that the two are spelled identically and are not the same thing, so a rule you learned about Buffer&& does not automatically hold for T&&; cppreference's forwarding references page is the short version on the day you need it. The book writes one twice, and both exist to hand an argument on untouched: Chapter 8's Result builds its variant in place through one, and Recipe 28 in Appendix F times a call through one.

The canonical exercise: Rule of Five for a raw buffer

Learn this shape cold:

class Buffer {
    size_t size_ = 0;
    int*   data_ = nullptr;

public:
    explicit Buffer(size_t size)
        : size_(size), data_(new int[size]{}) {}

    // 1. Destructor
    ~Buffer() { delete[] data_; }

    // 2. Copy constructor - deep copy
    Buffer(const Buffer& other)
        : size_(other.size_), data_(new int[other.size_])
    {
        std::copy(other.data_, other.data_ + size_, data_);
    }

    // 3. Copy assignment - copy-and-swap idiom
    Buffer& operator=(const Buffer& other) {
        Buffer tmp(other);   // deep copy (may throw - fine, we're untouched)
        swap(tmp);           // steal tmp's guts
        return *this;
    }                        // tmp's destructor frees OUR old data

    // 4. Move constructor - steal and null out
    Buffer(Buffer&& other) noexcept
        : size_(other.size_), data_(other.data_)
    {
        other.size_ = 0;
        other.data_ = nullptr;  // CRITICAL: or its destructor frees OUR data
    }

    // 5. Move assignment
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;           // free what we hold
            size_ = other.size_;
            data_ = other.data_;      // steal
            other.size_ = 0;
            other.data_ = nullptr;    // leave source empty-but-valid
        }
        return *this;
    }

private:
    void swap(Buffer& other) noexcept {
        std::swap(size_, other.size_);
        std::swap(data_, other.data_);
    }
};

Details that separate working code from correct code

  • Nulling out the source in moves. Forget it and the moved-from object's destructor deletes the data you just stole — double-free. The #1 bug in first attempts.
  • noexcept on move operations. Not decoration: std::vector checks it. When reallocating, vector only moves your elements if the move can't throw — otherwise it falls back to copying for exception-safety. Omit noexcept and your type silently copies inside vectors.
  • Self-assignment check (if (this != &other)) in move assignment — a = std::move(a) shouldn't destroy the data.
  • Copy-and-swap for copy assignment: copy into a temp, then swap. If allocation throws, your object is untouched (the strong exception guarantee), and self-assignment is handled for free.

The stance to hold: "In real code I'd never write this class — I'd hold std::vector<int> or unique_ptr<int[]> and get all five for free. Rule of Zero beats Rule of Five." Hand-rolling the five is a last resort; knowing how is what makes the shortcut safe.

Where moves matter in daily code

std::vector<Buffer> buffers;
buffers.push_back(std::move(myBuffer));  // move into container, no copy

widget.SetName(std::move(longString));   // sink params take by value + move
                                         // (what that costs: Appendix H)

std::unique_ptr<Shape> s = std::make_unique<Circle>();
shapes.push_back(std::move(s));          // unique_ptr can ONLY move - this
                                         // is how ownership transfer is spelled

In Rust

The Rule of Zero is the language. A move is a bitwise copy after which the source is dead — the compiler refuses the next use — so there is no moved-from state to reason about, no husk, and no move constructor to write: every type moves, for free, unless it opts into Copy. Copying is the thing you ask for, with Clone, and it is always a visible call. There is no assignment operator to get wrong: assigning over a value drops the old one first, in one order, always. std::move becomes nothing (moving is the default) or std::mem::take when you need the source left in a valid state on purpose — the husk this chapter's Tracer paints by hand. The value-category table collapses to two questions, do I own this or borrow it and is the borrow shared or exclusive, and the traps priced above cannot be written: moving out of a const becomes moving out of a shared borrow, which is an error rather than a silent copy.

In the wild: C-style SDKs

The commonest place this chapter is paid for is not a class you write but one you inherit: a 2009 class that owns a raw pointer, never declared its copy, and was never copied — until a feature copies it. Chapter 45 is that ticket, and its first seam is two lines from this chapter: = delete, before anything else is touched. Large C++ SDKs often ship their own unique_ptr analog (an "Owner" or "ScopedRef" type) with the same move-only behavior. Any RAII guard you write around SDK handles is exactly the "class holding a raw resource" case — either delete copy/move entirely (simplest, as in Chapter 1's guard), or implement moves properly when guards must be stored in containers or returned from factories (as Chapter 18's DeviceSession does — with a subtle twist worth meeting there).