Memory + RAII · #6 of 13

Move Semantics + Rule of Zero/Five

How `unique_ptr` transfers ownership, why `std::move` doesn't move anything, and the special members you should usually not write

Why it matters

Lesson 01 said C++ copies by default. Lesson 05 showed unique_ptr, which cannot be copied. The bridge between those two statements is move semantics — the language feature, added in C++11, that lets you transfer the guts of one object into another without the cost of a full copy.

Once you see moves, you stop thinking of std::vector as “the heavy container” and start thinking of it as “the container that costs nothing to return from a function.” You also stop reflexively writing destructors, copy constructors, and assignment operators, because the compiler is usually doing exactly the right thing.

The motivating problem

Imagine a Buffer type that owns a heap allocation:

struct Buffer {
  char* data;
  size_t size;

  Buffer(size_t n) : data(new char[n]), size(n) {}
  ~Buffer() { delete[] data; }
};

Now return one from a function:

Buffer make_big_buffer() {
  Buffer b(1'000'000);
  // … fill b.data …
  return b;        // ← what happens here?
}

Without move semantics, the language has two options:

  1. Copy the buffer: allocate a million bytes again, memcpy them, free the original. Wasteful — you’re literally about to throw away the source.
  2. Forbid returning by value: force the caller to pass in a buffer to fill. Awkward and viral.

Move semantics is option 3: steal the guts. The returned Buffer takes the original’s data pointer and size, then sets the original’s data to nullptr so its destructor frees nothing. Cost: a few pointer assignments. Zero allocations.

std::move doesn’t move anything

The name is misleading. std::move(x) is a cast. It says “treat x as an rvalue from now on, so move-construction and move-assignment can bind to it.”

The actual moving is done by the move constructor and the move assignment operator — special member functions the compiler generates for you, or that you write yourself when the defaults aren’t right.

After `std::string b = std::move(a);` runs, what does the second print of `a` show — the original text, the empty string, or undefined behavior?
#include <iostream>
#include <string>
#include <utility>          // for std::move

void demo(const std::string& label, std::string s) {
std::cout << label << "  data=\"" << s << "\"  size=" << s.size() << "\n";
}

int main() {
std::string a = "hello, world, this string is just past SSO";
std::cout << "before any moves:\n";
demo(" a ->", a);                   // copy: a still owns its heap buffer

std::string b = std::move(a);       // move: b steals a's buffer
std::cout << "after  std::move(a) into b:\n";
demo(" a ->", a);                   // a is now valid but unspecified
demo(" b ->", b);                   // b owns the original buffer
return 0;
}
idle

`std::move(a)` doesn't change `a`. The move constructor that runs next does. After the move, `a` is in a valid-but-unspecified state — empty for `std::string`.

Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out
before std::string b = std::move(a);
stack heap main() std::string a = → heap-buf char[44] hello, world, this…
after
stack heap main() std::string a = (empty) std::string b = → heap-buf char[44] hello, world, this…
The heap buffer never moves — only the pointer in the string's stack-resident header migrates from `a` to `b`. `a` ends up empty (valid, destructible), not pointing at the same buffer (which would double-free).

The contract for any well-behaved type:

After being moved from, an object is in a valid but unspecified state. You can assign to it, destruct it, or call methods documented to work on moved-from objects. You should not assume any particular value.

For the standard-library types, “valid but unspecified” usually means “empty.” For your own types, you choose — but pick something that’s safe to destruct.

Inside a move constructor

Concretely, the difference between copy and move:

struct Buffer {
  char*  data;
  size_t size;

  // Copy constructor: deep copy.
  Buffer(const Buffer& other)
    : data(new char[other.size]), size(other.size)
  {
    std::memcpy(data, other.data, size);
  }

  // Move constructor: steal the pointer, leave the source empty.
  Buffer(Buffer&& other) noexcept
    : data(other.data), size(other.size)
  {
    other.data = nullptr;
    other.size = 0;
  }

  ~Buffer() { delete[] data; }   // delete[] of nullptr is harmless
};
before Buffer dst(std::move(src));
stack heap main() Buffer src = {data, size} Buffer dst = (uninit) char[N] [ user bytes ]
after
stack heap main() Buffer src = {nullptr, 0} Buffer dst = {data, size} char[N] [ user bytes ]
The move constructor copies the pointer (one machine word), then nulls out the source. Both `Buffer` objects are valid; only one owns the heap buffer.

The signature Buffer&& is the rvalue reference — a reference that binds to temporaries (prvalues) and std::move’d-from objects (xvalues), but not to regular named lvalues. The compiler picks the move constructor when its argument is an rvalue, and the copy constructor otherwise. That’s how the same syntax — Buffer b = a; — copies if a is an lvalue and moves if a is std::move(a).

The noexcept on the move constructor is not decoration. std::vector specifically refuses to use a non-noexcept move constructor during reallocation, falling back to copies instead — because if a move throws halfway through, the vector can’t safely restore its previous state. Always declare move operations noexcept when they can be.

The compiler often elides moves entirely

Here’s the kicker: even when a function returns a Buffer by value, the compiler will often elide the move entirely, constructing the result directly in the caller’s storage. This is copy elision (or, when the return slot is the variable being returned, NRVO — Named Return Value Optimization).

#include <iostream>
#include <string>

struct Loud {
Loud()                 { std::cout << "  + ctor\n"; }
Loud(const Loud&)      { std::cout << "  + copy\n"; }
Loud(Loud&&) noexcept  { std::cout << "  + move\n"; }
~Loud()                { std::cout << "  - dtor\n"; }
};

Loud make() {
Loud x;        // constructed once, in the caller's slot (NRVO)
return x;      // no move, no copy
}

int main() {
std::cout << "calling make()…\n";
Loud result = make();    // initialized directly — one ctor total
std::cout << "main done\n";
return 0;
}
idle

The compiler constructs `x` directly in main's `result`. No copy. No move. The optimization is mandatory for prvalues in C++17+ and standard practice for NRVO since well before.

expected output
calling make()…
+ ctor
main done
- dtor
Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out
without NRVO (the hypothetical, slow path)
stack make() Loud x = → ctor in make's frame main() Loud result = (uninit)
with NRVO (what actually runs)
stack main() Loud result = constructed in place
With NRVO, the compiler constructs `x` directly in `main`'s return slot — there's never a separate object to copy or move. One ctor total, one dtor total.

This is why “always return by value” is the modern C++ idiom. The compiler makes it free. The old “pass an out parameter by reference to avoid copies” pattern is obsolete unless profiling tells you otherwise.

The Rule of Five (and the Rule of Zero)

The compiler will generate up to five special member functions for any class you define:

  1. Default constructorT()
  2. Destructor~T()
  3. Copy constructorT(const T&)
  4. Copy assignmentT& operator=(const T&)
  5. Move constructorT(T&&)
  6. Move assignmentT& operator=(T&&)

(Six total, but the first one is conventionally counted separately. The “Five” is the four copy/move plus destructor.)

The compiler generates defaults that shallow-copy each member. For a type that doesn’t own anything raw — i.e., its members are all standard containers, smart pointers, integers, etc. — those defaults are correct.

This gives the Rule of Zero:

If your class owns nothing that needs custom cleanup, write none of the special members. Let the compiler do it. Use std::unique_ptr / std::vector / std::string for the things that would otherwise require custom cleanup.

When does the Rule of Zero fail? When you wrap a raw resource — a C library handle, a custom allocator, a hardware interface. Then you owe yourself the Rule of Five:

If you write a destructor, you almost certainly need to write (or explicitly delete) the four copy/move operations too. The defaults the compiler generates for them are usually wrong for a type that needs custom cleanup.

In practice: write the destructor, then for each of the other four, decide: defaulted (= default;), deleted (= delete;), or custom. Be explicit. The compiler used to silently skip generating moves when you declared a destructor — leading to silent performance bugs. Modern advice is to write all five, even if four are just = default;.

A real Rule-of-Five class

For reference, a minimal Buffer written correctly:

struct Buffer {
  char*  data = nullptr;
  size_t size = 0;

  Buffer() = default;
  explicit Buffer(size_t n) : data(new char[n]), size(n) {}
  ~Buffer() { delete[] data; }

  Buffer(const Buffer& o)
    : data(o.size ? new char[o.size] : nullptr), size(o.size)
  {
    std::memcpy(data, o.data, size);
  }
  Buffer& operator=(const Buffer& o) {
    if (this != &o) {
      Buffer tmp(o);                // copy-and-swap
      std::swap(data, tmp.data);
      std::swap(size, tmp.size);
    }
    return *this;
  }

  Buffer(Buffer&& o) noexcept : data(o.data), size(o.size) {
    o.data = nullptr;
    o.size = 0;
  }
  Buffer& operator=(Buffer&& o) noexcept {
    if (this != &o) {
      delete[] data;
      data = o.data; size = o.size;
      o.data = nullptr; o.size = 0;
    }
    return *this;
  }
};

Then realize that Buffer is just std::vector<char>. You almost certainly don’t need to write any of that yourself — std::vector already did it, correctly, with noexcept moves and exception safety guarantees.

This is the punchline of the lesson. Write the Rule of Five only when no standard container fits. Otherwise, write nothing — and std::vector

When std::move is the wrong reflex

A few situations where std::move is unnecessary or harmful:

The mental model: std::move is an opt-in to “I won’t use this name again.” Use it when that’s true. Skip it otherwise.

Key takeaways

What’s next

The next phase shifts from memory and lifetime to what the standard library actually contains — containers, iterators, algorithms — and how they were all designed to be the value-typed, move-aware, zero-cost toolkit that the first six lessons set you up to use.

Capstone C1 — Custom Allocator — is also now unlocked. The strip on the hub points at the rubric: write a slab allocator, profile it against new/delete, and convince yourself the abstractions really are free.