all_lessons/C++/06 · Copy, move & value semanticslesson 7 / 20

Copy, move, and value semantics

Lesson 05 gave us RAII: a destructor that runs deterministically at scope exit, so a resource is freed exactly when its owning object dies. That solves destruction. But it silently raises a new question the moment an object is copied or returned: if two objects' destructors both run, and both think they own the same heap buffer, the buffer is freed twice — the double-free from Lesson 04, now caused by RAII itself. This lesson adds the one missing piece: how values are copied and moved. C++ defaults to value semantics — assignment and argument-passing copy by default — and the new idea is that you control what "copy" means for your type, and that you can move (transfer a resource) instead of duplicating it, for free.

The thesis, here
In a garbage-collected language, b = a almost always shares — both names point at one object, and no copy happens. C++ defaults to the opposite: b = a copies, so each object is independent. That single default is why C++ data flow is visible: a copy is a real, itemizable cost (duplicate N bytes), a move is a cheaper one (steal a pointer, O(1)), and a share is something you must ask for explicitly (Lesson 07). The cost-model question "what did this assignment cost?" has a concrete, local answer.
Linear position
Prerequisite: Lesson 05 (RAII — destructors free a resource at scope exit), built on Lesson 04 (heap, and the leak / dangle / double-free trio) and Lesson 03 (a pointer is an address you can hold). You should be comfortable that a class can hold a raw T* to a heap buffer it owns.
New capability: reason about exactly what happens when an object is copied, assigned, returned, or passed by value; write a correct owning type using the rule of zero / three / five; and know when a copy (duplicate the bytes) silently becomes a move (steal the pointer, O(1)).
The plan
Six moves. (1) Define value semantics and contrast it with the reference semantics of Python/Java. (2) Introduce the copy constructor and copy assignment — the two functions that say what a copy is. (3) Deep vs shallow copy, and trace the double-free that a wrong copy causes. (4) Move semantics: rvalue references, std::move, and the moved-from object. (5) The rule of zero / three / five — when you must write these functions and when you must not. (6) A worked Buffer class with copy cost vs move cost in concrete numbers.

1 · Value semantics: the default that flips your intuition

Value semantics means a variable is its value, not a handle to a value living elsewhere. When you write b = a, b becomes an independent copy; changing b afterward does not touch a. Passing an object to a function by value hands the function its own copy. This is the C++ default for every type — built-in or user-defined.

If your background is Python or Java, this is the inverse of what you expect. There, a variable is a reference (a hidden pointer) to a heap object, and assignment copies the reference, not the object — both names alias one object. That is reference semantics.

// C++ — value semantics (objects ARE values)
std::string a = "hello";
std::string b = a;   // b is an independent COPY of a's characters
b[0] = 'j';          // a is still "hello"; b is "jello"

# Python — reference semantics (variables are handles)
a = [1, 2, 3]
b = a                # b and a name the SAME list
b[0] = 99            # a is now [99, 2, 3] too

The C++ default has a cost (copying may duplicate a lot of bytes) and a payoff (no aliasing surprises, and the cost is visible at the point it happens). Reference semantics has the opposite trade: cheap assignment, but mutation can leak across the program through shared aliases — the bug at the end of Lesson 00's Python example. C++ lets you opt into sharing deliberately (a reference, or a shared_ptr in Lesson 07), but you never get it by accident.

2 · The copy constructor and copy assignment operator

For a class, "what does a copy mean?" is answered by two special member functions the compiler will call for you:

The parameter is const T& — a reference to the source (Lesson 03), so we read it without copying it (a by-value parameter here would need a copy to copy, which is circular). The distinction between the two functions is just does the target already exist? — construction builds from nothing; assignment must first dispose of whatever the target held.

If you write none of them, the compiler implicitly generates both: it copies each member in turn (the memberwise copy). For a type whose members are themselves value types — an int, a std::string, a std::vector — that is exactly right and you write nothing. The trouble is precisely the type that owns a raw pointer.

3 · Deep vs shallow copy — and the double-free

Suppose a class owns a heap array through a raw pointer int* data and a size. The compiler's memberwise copy copies the pointer value — the address — not the bytes it points at. That is a shallow copy: two objects now hold the same address. A deep copy would instead allocate a fresh buffer and copy the N elements into it, so each object owns a distinct buffer.

Shallow-copying an owning pointer is a disaster, and Lesson 05 is exactly why. Each object's destructor runs delete[] data; at scope exit. With two objects holding the same address, that address is freed twice — the double-free from Lesson 04 — and freeing already-freed memory is undefined behavior (corrupts the allocator; often a crash). RAII didn't cause the bug, but it guarantees the second destructor fires, turning a latent aliasing mistake into a certain crash.

Worked trace — the double-free a default copy causes
struct Bad {
    int*  data;
    int   size;
    Bad(int n) : data(new int[n]), size(n) {}   // acquires (Lesson 05)
    ~Bad() { delete[] data; }                    // releases
    // NO copy constructor written -> compiler generates a SHALLOW one
};

void boom() {
    Bad a(1000);          // a.data = 0x6000   (heap buffer, 4000 bytes)
    Bad b = a;            // shallow copy: b.data = 0x6000  (SAME address!)
}                         // scope exit: ~b runs delete[] 0x6000  -> freed
                          //             ~a runs delete[] 0x6000  -> FREED AGAIN  (double free, UB)
The fix is to give the type a copy constructor that does a deep copy, so the two objects own different buffers and each destructor frees its own:
Bad(const Bad& o) : data(new int[o.size]), size(o.size) {
    std::copy(o.data, o.data + o.size, data);   // duplicate all o.size ints
}
Now b gets its own 4000-byte buffer at a different address; both destructors free distinct memory. Correct — but it cost a full duplication of the array. That cost is the motivation for move.

4 · Move semantics: transfer the resource instead of copying it

Consider returning a freshly-built buffer from a function, or inserting a temporary into a container. The source object is about to be destroyed anyway — it is a temporary. Deep-copying it into the destination and then deleting the temporary's now-useless buffer is pure waste: we duplicate N bytes only to throw the original away. A move avoids that by transferring ownership of the existing buffer — copy the pointer, then null out the source so its destructor frees nothing.

To express "this source is expendable, you may pillage it," C++ adds the rvalue reference, written T&&. The terms: an lvalue is an object with a name and an identity that persists (a); an rvalue is a temporary with no name, about to expire (the result of make(), or anything you explicitly mark). An rvalue reference binds only to rvalues, which lets you overload on "is the source disposable?"

std::move is the tool you use to move from a named object: it does not move anything itself: it is just a cast that says "treat this lvalue as an rvalue — I promise I'm done with it." After T y = std::move(x);, x is in a moved-from state: a valid but unspecified value. The contract is that you may destroy it or assign a new value to it, but you must not assume what it holds. A well-written move leaves the source empty (pointer set to nullptr, size 0) so its destructor is a no-op.

Two failure modes around moved-from state
(1) Using a moved-from object's value. auto y = std::move(x); use(x);x is valid but unspecified; reading its old contents is a bug. (2) Forgetting to null the source in your move constructor. If the move copies the pointer but doesn't null other.data, both objects hold the same address and you are back to the double-free of §3 — a move that didn't actually transfer ownership. Always leave the source empty, and mark moves noexcept (containers like vector will only use your move on reallocation if it cannot throw — otherwise they fall back to the slow copy).

5 · The rule of zero / three / five

These are the rules for which of the special functions you must define. There are five, all governing the lifetime of a resource a class holds: destructor, copy constructor, copy assignment, move constructor, move assignment.

Rule of zero (the goal)
Write none of the five. Hold your resources in members that already manage themselves — std::string, std::vector, std::unique_ptr (Lesson 07). The compiler-generated copies/moves/destructor are then all correct, because each member does the right thing. This is the target for almost every class you write.
Rule of three (classic)
If you must write any one of {destructor, copy constructor, copy assignment} — usually because you hold a raw owning pointer — you almost certainly need all three. A custom destructor means the class owns something; ownership means the default shallow copy is wrong (§3).
Rule of five (modern)
Once you write any of the three, the compiler stops generating the move operations (and, conversely, declaring a move operation deletes the implicit copies). The dangerous asymmetry: a user-declared destructor still leaves the implicit copy being generated — only deprecated, not removed — which is exactly the shallow-copy double-free trap in §3. To keep moves cheap you add the move constructor and move assignment too — all five. In practice: prefer the rule of zero; if you can't, write all five.

The deep lesson is that the rule of zero is reachable for nearly everything: the members do the resource management, so your class is just an aggregate of self-managing parts. You write the five functions only at the lowest layer — inside vector, inside unique_ptr, or in a deliberately hand-rolled owning type like the worked example below. Lesson 07 shows that unique_ptr is precisely "the rule of five, written once, so your class can use the rule of zero."

6 · Worked example: a Buffer class, with the numbers

Here is an owning type written to the rule of five — managing n doubles (8 bytes each) on the heap. Read each function as acquire / release / duplicate / steal.

class Buffer {
    double* data_;
    std::size_t n_;
public:
    explicit Buffer(std::size_t n) : data_(new double[n]), n_(n) {}   // acquire

    ~Buffer() { delete[] data_; }                                     // release (RAII, L05)

    Buffer(const Buffer& o) : data_(new double[o.n_]), n_(o.n_) {     // COPY ctor: deep
        std::copy(o.data_, o.data_ + o.n_, data_);                    //   duplicate n_ doubles
    }
    Buffer& operator=(const Buffer& o) {                               // COPY assign
        if (this == &o) return *this;                                 //   guard self-assign
        double* fresh = new double[o.n_];                             //   allocate BEFORE freeing
        std::copy(o.data_, o.data_ + o.n_, fresh);                    //   (strong guarantee, L15)
        delete[] data_;                                              //   free old buffer
        data_ = fresh; n_ = o.n_;
        return *this;
    }
    Buffer(Buffer&& o) noexcept : data_(o.data_), n_(o.n_) {          // MOVE ctor: steal
        o.data_ = nullptr; o.n_ = 0;                                  //   leave source EMPTY
    }
    Buffer& operator=(Buffer&& o) noexcept {                           // MOVE assign
        if (this == &o) return *this;
        delete[] data_;                                              //   free our own buffer
        data_ = o.data_; n_ = o.n_;                                   //   steal pointer (O(1))
        o.data_ = nullptr; o.n_ = 0;                                  //   leave source EMPTY
        return *this;
    }
};
Worked numbers — copy vs move on a 1,000,000-element Buffer
A Buffer of 1,000,000 doubles owns an 8,000,000-byte (8 MB) heap array.
OperationWhat runsHeap allocationsBytes touchedCost
Buffer b = a; (copy)copy ctor1 new 8 MB buffer8,000,000 copiedO(n) — millions of stores
Buffer b = std::move(a); (move)move ctor0~16 (one pointer + one size)O(1) — a handful of stores
The copy duplicates 8 MB; the move copies a pointer and a size — roughly two machine words — and nulls the source. Same observable result (the destination owns an 8 MB buffer), but the move is the difference between megabytes and bytes. This is why returning a Buffer from a function is cheap: the return value is a temporary (an rvalue), so the move constructor is selected automatically — no std::move needed on a return.

Notice value semantics made all of this visible: every duplication of those 8 MB happens at a syntactically obvious point (an assignment, a by-value pass, a copy ctor call), and you can see — and choose — whether it is a copy or a move. In a reference-semantics language the 8 MB would be silently shared, which is cheaper but means a mutation through one handle could surprise the holder of another.

Common mistakes / failure modes

Default copy on an owning raw pointer
Compiler-generated shallow copy → two objects, one buffer → double free at scope exit (§3). Fix: rule of three/five, or hold the buffer in a vector/unique_ptr and use the rule of zero.
Move that forgets to empty the source
Stealing the pointer without setting other.data_ = nullptr leaves both pointing at one buffer — double free again. A move must transfer, leaving the source destructible-but-empty.
Reading a moved-from object
After std::move(x), x is valid but unspecified. Only destroy or reassign it; don't read its old value.
Passing a huge object by value unintentionally
A by-value parameter copies (§2). For a big object you only read, take const T& — the value-semantics default charges you a full copy if you forget.
No self-assignment guard in copy assignment
b = b; that frees data_ before reading from it corrupts itself. Guard with if (this == &o), or allocate the fresh buffer before deleting the old (§6).
Move operations not noexcept
vector won't use a throwing move on reallocation — it silently falls back to copying every element. Mark moves noexcept to actually get the O(1) path.

Checkpoint exercise

Try it
Take the Bad struct from §3 (raw int* data, custom destructor, no copy ctor). Write a main that does Bad a(1000); Bad b = a; and returns. Predict: does it leak, dangle, or double-free? Now compile with g++ -std=c++20 -fsanitize=address bad.cpp && ./a.out and read AddressSanitizer's report — you should see "attempting double-free" with two stack traces pointing at the same destructor. Then add the deep copy constructor from §3 and confirm the report disappears. Finally, add a move constructor that steals the pointer but forgets to null other.data, do Bad c = std::move(a);, and watch the double-free return — proving a move is only correct if it empties the source.

Where this points next

We now know how a class can correctly own a raw resource — but writing all five functions by hand, every time, is exactly the manual discipline Lesson 04 warned is unwinnable at scale. Lesson 07 makes the obvious move: package "owns a heap resource, rule of five done right" into a reusable type. std::unique_ptr is sole ownership written once (move-only — it uses precisely the move semantics from this lesson, and deletes the copy operations so a double-free is impossible to write); std::shared_ptr is opt-in shared ownership at the cost of an atomic reference count. With those, your own classes drop to the rule of zero, and the leak / dangle / double-free trio from Lesson 04 is finally closed by the type system.

Takeaway
C++ defaults to value semantics: b = a and by-value passing copy, producing independent objects — the inverse of Python/Java reference semantics, and the reason every duplication is a visible, itemizable cost. The copy constructor and copy assignment define what a copy means; for a type owning a raw pointer the compiler's default shallow copy aliases one buffer into two objects, and RAII's two destructors then double-free it (Lesson 04) — so you must deep-copy. Move semantics (rvalue references T&&, std::move) transfers a resource instead of duplicating it: the move constructor steals the pointer and empties the source, turning an O(n) byte-duplication into an O(1) pointer swap — 8 MB versus ~16 bytes for a million-element buffer. The rule of zero / three / five says: prefer holding self-managing members and write none of the five; if you must hold a raw resource, write all five (and mark moves noexcept). Value semantics is the feature that makes data flow — copy, move, or deliberate share — visible at the line where it happens.

Interview prompts