JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Value Semantics, Move Semantics, and the Rule of Five

The previous guide showed how RAII ties a resource's lifetime to an object's scope. This one asks the next question: when you copy or return such an object, what happens to the bytes — and to the heap block it owns? Value semantics, move semantics, and the rule of five are the three answers C++ gives, and together they are what make handing a buffer around cost almost nothing.

What a value is, and why the default copy bites

Start from the thing you already trust from C: a plain `int x = 7;` is a value. It lives in some bytes, and when you write `int y = x;` the bytes are copied — now `y` is an independent 7 that knows nothing about `x`. Change one, the other is untouched. This is value semantics, and it is the default in C++ for every type: assignment and passing an argument copy the object, not share it. The whole rung you just climbed taught you that a class can hold a resource through RAII — a heap block, a file descriptor, a lock. The uncomfortable question is what "copy the object" should mean when the object owns one of those.

Picture a tiny string class that owns a heap buffer: it stores `char *data` and a `size_t len`, allocates with `new[]` in its constructor, and frees with `delete[]` in its destructor — textbook RAII. Now write `Str b = a;`. If C++ does the obvious thing and copies the bytes of the object, it copies the pointer value `data` — not the buffer it points at. You now have two `Str` objects whose `data` fields hold the same address. This is the shallow copy trap. The two objects are not independent at all; they share one heap block they both believe they own.

The bug detonates at scope exit. When `b`'s destructor runs it calls `delete[] data`, freeing the buffer. When `a`'s destructor runs it calls `delete[]` on the same address — a deterministic, guaranteed double free, which is undefined behavior. And if either object had been mutated, the other saw the change through the shared pointer. So the default member-by-member copy is exactly wrong for any class that owns a resource. The language is not broken; it simply does not know your `char *data` is an owning pointer rather than a borrowed view. You have to tell it.

Deep copy fixes correctness — and exposes a cost

The fix you reach for first is the deep copy: when you copy a `Str`, allocate a fresh buffer and copy the characters into it, so each object owns its own block. In C++ this is done by writing a copy constructor and a copy assignment operator — two of the special member functions the compiler would otherwise generate for you. The copy constructor runs `new[]` for a new buffer the same length as the source and copies the bytes across; the copy assignment operator does the same but must first free its old buffer (and guard against `a = a;`). Now `Str b = a;` produces two genuinely separate strings, each destructor frees its own block exactly once, and the double-free is gone.

Correct — but now feel the cost. Consider a function `Str make_label() { Str s("..."); ... ; return s; }`. Returning `s` by value, and the call `Str label = make_label();`, conceptually want to copy the whole buffer out of the function. Or take `push_back` on a growing vector of `Str`: when the vector reallocates, every element must be copied to the new storage, each copy meaning a fresh `new[]` and a byte-by-byte duplication. For a megabyte-sized string this is a megabyte of allocation and copying — and in the return case, the source `s` is about to be destroyed anyway. We are deep-copying a buffer only to throw the original away an instant later. That waste is the gap move semantics fills.

Move: steal the buffer instead of copying it

Here is the central idea. When the source of a copy is a temporary or is about to die, copying its buffer is pointless — we could just take the buffer. Move semantics is exactly that: instead of allocating a new block and duplicating bytes, the destination steals the source's pointer and length, then sets the source's pointer to `nullptr` and its length to 0 so the source's destructor frees nothing. No `new[]`, no byte copy — just three pointer-sized assignments. A megabyte string "moves" in a handful of instructions regardless of its size. The buffer never moved at all; only the ownership of it did.

// move constructor: take, then null out the source
Str(Str&& other) noexcept
    : data(other.data), len(other.len)   // steal
{
    other.data = nullptr;                // leave source empty
    other.len  = 0;                      // its dtor frees nothing
}
A move constructor steals the pointer and zeroes the source — no allocation, no byte copy.

How does the compiler know it is allowed to steal rather than copy? Through a second kind of reference. An ordinary `Str&` binds to a named object that lives on — a so-called lvalue. A new kind, the rvalue reference written `Str&&`, binds only to things that are temporary or explicitly marked as expendable. The compiler picks the move constructor whenever the source is an rvalue: a temporary like the return value of `make_label()`, or any value you have promised not to use again. So returning a local by value, and constructing from a temporary, automatically move — the wasteful deep copy from the last section silently becomes a cheap pointer steal, with no change to your call sites.

When you do have a named object you are finished with — say a local `s` you want to hand off without copying — you opt in with std::move. Despite the name, `std::move` moves nothing and generates no code: it is purely a cast that produces an `Str&&` from your lvalue, telling the compiler "treat this as expendable, the move constructor may steal from it." After `Str b = std::move(a);`, the bytes of `b` are what `a` used to own, and `a` is in a valid-but-empty state: you may assign to it or let it be destroyed, but you must not assume it still holds its old value. That moved-from rule is the one genuine sharp edge here, and it is worth saying plainly.

The rule of five — and the better rule of zero

Step back and count what a resource-owning class needs. To free its buffer it needs a destructor. To copy correctly it needs a copy constructor and a copy assignment operator (deep copy, no double free). To move cheaply it needs a move constructor and a move assignment operator (steal and null out). That is five special member functions, and the observation that they come as a set is the rule of five: if your class manually manages a resource and you write any one of these, you almost certainly need to think about all five, because the compiler-generated defaults for the others will be the wrong shallow-copy behavior that double-frees.

The history is worth one sentence: before C++11 there were only four such functions (destructor, copy constructor, copy assignment, plus the default constructor sometimes counted in), and the guidance was the "rule of three." C++11 added move constructor and move assignment, turning three into five. The numbers are a mnemonic, not a law; the real content is "these special members are interdependent for a resource-owning type, so design them together."

Now the punch line, which is also the real advice: the best number of special member functions to write is zero. This is the rule of zero. Instead of hand-managing a raw `char *data` and writing all five functions, hold the resource in a type that already manages itself correctly — a `std::vector<char>`, a `std::string`, or a smart pointer (the very next guide). Those library types have correct copy, move, and destruction built in. A class made only of such members needs none of the five: the compiler-generated defaults do exactly the right thing, because each member already deep-copies, moves, and frees itself. The rule of five tells you what to do when you truly must manage a resource by hand; the rule of zero tells you to arrange your code so you almost never must.

Why this is the zero-overhead bargain, and what comes next

Stand back and see what value-plus-move buys you. You get to write code in a clean style — pass objects by value, return them by value, store them in containers — as if everything were a cheap `int`, while the heap buffers underneath are never needlessly duplicated. Returning a million-byte string out of a function costs three pointer assignments, not a million-byte copy. This is the zero-overhead principle in action: a safer, value-based style that compiles down to the same pointer-shuffling you would have hand-written in C, with no garbage collector and no run-time bookkeeping. The cost was paid once, in the design of the five (or zero) special members.

Walk the lifecycle once, slowly, to lock it in:

  1. A `Str s("hello")` constructor runs `new[]`, so `s` owns a heap block — RAII establishes ownership at construction.
  2. `Str t = s;` (a copy of a named object) runs the copy constructor: a fresh `new[]` plus a byte copy, so `t` and `s` are independent.
  3. `Str u = make_label();` (the source is a temporary) runs the move constructor: `u` steals the temporary's pointer, no allocation, no byte copy.
  4. `Str v = std::move(s);` casts `s` to an rvalue, so the move constructor steals from it; afterward `s` is valid-but-empty and must not be read for its old value.
  5. At scope exit each destructor runs once on the block it actually owns — `nullptr` for the moved-from one — so every buffer is freed exactly once, with no double free and no leak.

Notice what every step above leans on: a smart member type that already does copy, move, and free correctly. That is precisely the next guide. std::unique_ptr and std::shared_ptr are the standard library's answer to "give me a type that owns a heap block and obeys the rule of five so I never have to." With them, the rule of zero becomes the normal case, and the manual five-function dance you just learned becomes the rare thing you reach for only when writing a low-level container of your own.