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:
- Copy the buffer: allocate a million bytes again, memcpy them, free the original. Wasteful — you’re literally about to throw away the source.
- 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.
#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;
}
`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 any moves:
a -> data="hello, world, this string is just past SSO" size=44
after std::move(a) into b:
a -> data="" size=0
b -> data="hello, world, this string is just past SSO" size=44 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
};
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;
}
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.
calling make()…
+ ctor
main done
- dtor Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out 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:
- Default constructor —
T() - Destructor —
~T() - Copy constructor —
T(const T&) - Copy assignment —
T& operator=(const T&) - Move constructor —
T(T&&) - Move assignment —
T& 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::stringfor 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
std::unique_ptrwill get the moves right for you.
When std::move is the wrong reflex
A few situations where std::move is unnecessary or harmful:
- Returning a local by value.
return x;already does NRVO; addingreturn std::move(x);actually defeats NRVO and forces a real move. Trust the compiler. - Pass-by-value parameters that you then assign. Inside
void take(Buffer b), writingmember_ = std::move(b);is correct —bis a local you can mine. constobjects.std::move(c)wherecisconst Tquietly produces aconst T&&, which then binds to the copy constructor. You get a copy you didn’t want, with no warning.- Trivial types.
std::move(42)is silly. Moving anintis the same as copying one.
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
std::move(x)is a cast. The actual stealing is done by a move constructor or move assignment operator, which the compiler picks because the argument is now an rvalue.- Moved-from objects are valid but unspecified. You can destruct or reassign; don’t assume any particular value.
- Move constructors should be
noexcept. Otherwise containers fall back to copying during reallocation. - The compiler often elides moves entirely (NRVO / copy elision). Use return-by-value freely; the optimizer makes it free.
- Rule of Zero: own nothing raw, write no special members.
- Rule of Five: if you write the destructor, write (or
= default;/= delete;) the four copy/move operations too. Be explicit. - Reach for
std::vector/std::unique_ptrbefore writing custom ownership. They’ve already done the Rule of Five correctly.
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.