Module 10 — Concepts and Explanations¶
This module has one idea at its centre, and the whole page is the walk towards it:
A class should manage at most one resource, and a class that manages one must take control of copying — or refuse to be copied, or own nothing at all.
You will arrive there the long way round, starting from a class that looks entirely correct and is not.
Every snippet below is small enough to type, compile and run. Do that. This is the one module where reading without running will leave you confident and wrong.
1. This class works. What happens when I copy it?¶
A class that owns a heap block:
class RawBuffer {
public:
explicit RawBuffer(std::size_t n) : size_(n), data_(new int[n]{}) {}
~RawBuffer() { delete[] data_; } // releases what we own
int& at(std::size_t i) { return data_[i]; }
std::size_t size() const { return size_; }
private:
std::size_t size_;
int* data_; // OWNED
};
Used by itself, this class is fine. Construct it, use it, let it die: one new[], one
delete[].
Now copy it:
Predict, then read on: how many heap blocks exist after that second line — one or two?
The answer is one, and the picture is worth drawing:
You did not write a copy constructor, so the compiler wrote one for you — and the copy
constructor the compiler writes copies every member. For size_, that is exactly right. For
data_, a pointer, "copy the member" means copy the address, not the memory it points at.
What this is called: a shallow copy. The two objects are independent as far as their
size_ members go, and completely entangled as far as their storage goes.
What the compiler generated: a copy constructor T(const T&) and a copy assignment
operator T& operator=(const T&). You will meet the second one in section 5; the first is
the one that just ran.
2. Why did nothing go wrong at the copy?¶
Nothing did — yet. The copy was a bitwise duplicate of two members and it produced no
diagnostic at all, not even under -Wall -Wextra -Werror.
The damage is scheduled for destruction:
end of scope, destruction in reverse order of construction:
~RawBuffer() on `copy` delete[] data_ ← block freed here
~RawBuffer() on `original` delete[] data_ ← frees the SAME block again
That second delete[] is a double free: undefined behaviour. In practice it usually
crashes inside the allocator, some time later, in a function that has nothing to do with
copies. Sometimes it does not crash at all, and quietly corrupts the heap instead.
Two facts about this failure shape everything else in the module:
- The symptom appears at destruction, not at the copy — which points you at the wrong line when you start debugging.
- Nothing in the language warns you. There is no diagnostic for "this class owns a
pointer and did not say what copying means". The compiler cannot know whether
data_was meant to be owned.
examples/module-10/01-shallow-copy-alias/main.cpp reproduces this. A real double free
cannot be run safely, so that program's destructor keeps a record of the blocks it has freed
and reports a second attempt instead of performing it. Every other line is what the real
class contains.
3. Why does the copy even exist? I never wrote one.¶
Because copying is the default meaning of assignment and initialisation in C++, and the compiler assumes classes follow value semantics: copying a value gives you an independent value. For most classes that assumption is correct.
Here is the class where the generated copy is exactly right:
struct Reading {
std::string sensor; // owns its own characters, and says so
double value = 0.0;
std::string taken_at; // owns its own characters
};
Copy one and you get two independent Readings. std::string has its own copy constructor,
which allocates and copies the characters; nothing is shared. The compiler's member-wise
copy works because every member already manages its own resource.
So the rule is not "the compiler's copy is wrong". It is narrower and more useful:
The compiler's member-wise copy is wrong exactly when a member is a raw resource handle that the class is responsible for releasing.
The compiler also decides, on your behalf, which of the five special member functions to generate, based on which ones you declared. That table belongs here, because the surprises in it are silent:
| You declared | Compiler generates | Effect |
|---|---|---|
| Nothing | all five | Member-wise copies, member-wise moves |
| A destructor | copies, no moves | Moves silently become copies |
| A copy constructor or copy assignment | copies, no moves | Same, plus: the class is a copy type |
| A move constructor or move assignment | no copies, moves | The class becomes move-only |
A destructor, and = delete on copy |
no copies | The class is non-copyable, explicitly |
= default on all five |
all five | You keep the defaults and the table stops mattering |
The third row is the one that catches people: declaring a move operation deletes the copy operations. You can see the compiler say so, because the error message names the reason:
error: use of deleted function 'constexpr MoveOnly::MoveOnly(const MoveOnly&)'
note: 'constexpr MoveOnly::MoveOnly(const MoveOnly&)' is implicitly declared as deleted
because 'MoveOnly' declares a move constructor or move assignment operator
Read that note: carefully, because it is a complete explanation of the rule.
4. How do I take control?¶
Write the copy constructor yourself, and make it a deep copy: allocate a new block and copy the values into it.
class Buffer {
public:
explicit Buffer(std::size_t n) : size_(n), data_(new int[n]{}) {}
// A NEW object is being built, so we must allocate.
Buffer(const Buffer& other) : size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + other.size_, data_);
}
~Buffer() { delete[] data_; }
private:
std::size_t size_;
int* data_;
};
Now the picture has two blocks and two owners:
A rule of thumb worth keeping: every object that owns a resource should allocate exactly once for it, and release exactly once. Count that, and the class is usually right.
examples/module-10/02-rule-of-three/main.cpp counts allocations, frees and copies, so the
final line — allocations 5, frees 5, blocks alive 0 — is evidence rather than assertion.
Predict those three numbers before you run it.
5. What about a = a?¶
Copy construction builds a new object. Copy assignment replaces the contents of an object that already exists — which means it must first release what that object currently holds.
Buffer& operator=(const Buffer& other) {
delete[] data_; // release what I already own
size_ = other.size_;
data_ = new int[other.size_];
std::copy(other.data_, other.data_ + other.size_, data_);
return *this;
}
This is correct for ordinary assignment and catastrophic for one case:
The first line of the operator deletes the very block it is about to read from. other and
*this are the same object, so after delete[] data_ the source data is gone, and the copy
loop reads freed memory. Self-assignment is a real risk, not a contrived one: a = a
appears in real code as v[i] = v[j] where i == j, or *p = *q where both pointers happen
to address the same object.
Two ways to make it safe:
// (a) guard: cheap, explicit, and every reader sees why it is there
if (this == &other) { return *this; }
// (b) copy-and-swap: free of the question altogether
Buffer& operator=(Buffer other) { // NOTE: taken by value - already a copy
swap(*this, other);
return *this;
}
swap in one line each:
Copy-and-swap is worth understanding even if you later stop needing it, because three properties fall out of those two lines with no extra code:
- self-assignment is safe —
a = acopies first, then swaps two copies; - the strong exception guarantee — if the copy throws,
*thiswas never touched, so a failed assignment changes nothing at all; - the same function serves as move assignment once move operations exist, because a by-value parameter accepts a moved-from argument too.
examples/module-10/03-copy-and-swap/main.cpp demonstrates all three, including an
allocation that refuses to happen (a size limit) so that you can watch the strong guarantee
hold.
6. Why do these three come together?¶
Destructor, copy constructor, copy assignment. If you need one, you almost certainly need all three, because they are three views of one decision: what does this class own?
destructor releases the resource
copy constructor gives a NEW object its own resource
copy assignment releases the OLD resource, then takes on a new one
Write the destructor and forget the copy constructor, and every copy shares your resource. Write the copy constructor and forget the destructor, and the copies leak. That is the Rule of Three: if you declare any one of them, declare all three.
You can watch the compiler enforce the opposite direction, which is a good sanity check on your mental model. A class that deliberately forbids copying says so:
class NoCopy {
public:
NoCopy(const NoCopy&) = delete;
NoCopy& operator=(const NoCopy&) = delete;
};
and any attempt to copy it fails with an error that names the class and points at the deletion:
= delete is a design statement, not a trick. A mutex, a file handle, a device
connection: copying them is meaningless, so the type says so and the compiler enforces it.
A deleted function still participates in overload resolution — it is selected, and then
rejected — which is why the message says "use of deleted function" rather than "no matching
function". That distinction tells you the overload existed and you were refused, rather than
that nothing matched.
7. Can I avoid copying something I am about to throw away?¶
Often you do not need a copy at all: you need to move the resource out of an object that is about to be destroyed. That is what move construction does.
class Buffer {
public:
// Move constructor: steal `other`'s block, and leave `other` empty but valid.
Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr; // so its destructor has nothing to free
}
private:
std::size_t size_;
int* data_;
};
The cost of that function is two pointer-sized assignments, however large the block is: no
allocation, no copying of elements. The type Buffer&& is an rvalue reference, and it is
what makes the compiler prefer this constructor for an object that is a temporary or has been
marked as movable.
Three facts that decide whether moves happen at all:
std::move moves nothing. It is a cast — to an rvalue reference — and its entire job is to
make a move overload selectable. If no move overload exists, the cast has no effect on which
function runs, and you get a copy with no diagnostic:
Those two lines are from examples/module-10/04-move-semantics/main.cpp, measured rather than
asserted. In the same program there is a third line worth staring at:
consume(std::move(config.value)); copies 1 moves 0
-> a copy: the member is const, so the move was ignored
std::move on a const object produces a const T&&, and const T&& does not bind to
T&&. The only viable candidate was the copy constructor. No warning, no error, no hint —
just a hidden copy of something you thought you were moving. The rule that follows: a
const member is copied forever.
A moved-from object is valid but unspecified. The standard guarantees that you may destroy it and assign to it. It does not guarantee that it is empty, and it certainly does not guarantee its value. On this implementation a moved-from buffer reports size 0, and relying on that is relying on an implementation detail.
There is a nastier trap in the same territory: self-move. Moving an object into itself through a reference or pointer alias can silently empty it, because the source and the destination are the same members:
Both lines are real output from a Tracked-style class whose move assignment has no
self-check: the std::string member was moved into itself, and the object destroyed its own
contents. The compiler said nothing. The guard — if (this == &other) { return *this; } — is
one line in the move assignment operator, and it is worth writing.
8. Why does the compiler care whether my move is noexcept?¶
Because std::vector does. When a vector runs out of capacity it allocates a bigger buffer and
must transfer the existing elements into it. It has a choice: copy each element, or move it.
It offers a guarantee: if that transfer fails, the vector is exactly as it was. Moving elements out of the old buffer destroys the originals, so a throw halfway through would leave the vector half-empty and unrecoverable. Therefore:
A vector moves its elements only if the move cannot throw. Otherwise it copies.
The measurement is stark. Same class, same operations, only the exception specification differs:
Item<true>, noexcept move: constructions 15, copies 0, moves 7
Item<false>, throwing move: constructions 15, copies 7, moves 0
Seven moves became seven copies. Every copy of an element that owns a std::string allocates.
Nothing in the program says a word about it — that is the whole point of
examples/module-10/05-noexcept-moves/main.cpp, which measures both, then shows the two
cheaper fixes (reserve(), and emplace_back instead of push_back), and finally reproduces
the mechanism inside the standard library itself: std::move_if_noexcept.
The habit: write noexcept on both move operations, always. If a move genuinely cannot be
noexcept — because a member's move can throw — then know that the standard library will copy
instead, and that you will not be told.
9. Do I have to write all five?¶
No. You have two better options.
Option 1 — Rule of Five: declare all five. If a class must own a raw resource, write the
destructor, the copy constructor, the copy assignment operator, the move constructor and the
move assignment operator, all correct, all noexcept where they can be. The reason it is all
five and not "as many as I need" is section 3's table: declaring some of them changes which
ones the compiler generates, so a partial set produces a class with accidental behaviour in
the gaps.
Option 2 — Rule of Zero: declare none of them. Make the class own no resource directly, and let its members own things:
class Report {
public:
explicit Report(std::string title) : title_(std::move(title)) {}
void addFigure(int value) { figures_.push_back(value); }
std::size_t figureCount() const { return figures_.size(); }
private:
std::string title_; // owns its characters, correctly
std::vector<int> figures_; // owns its buffer, correctly
}; // no destructor, no copy, no move: none needed
std::string and std::vector already implement the Rule of Five correctly. "Member-wise
copy" is only a bug when a member is a raw owning pointer; here member-wise copy is precisely
what you want, and the compiler's version is better tested than anything you would write.
The Rule of Zero is the destination of the module, and the Rule of Five is the road there.
examples/module-10/06-rule-of-zero-and-elision/main.cpp walks the same scripted sequence
through a hand-written Rule of Five class and then through Report, with counters on both.
One more thing that example proves, because it stops a common over-correction: copy elision removes the copy, not the copy constructor's existence.
Instrumented x = Instrumented("direct"); copies 0 moves 0 new objects 1
Instrumented z = x; copies 1 moves 0 new objects 2
From C++17, initialising an object from a prvalue — T x = f(); where f returns T by
value — is guaranteed to construct it in place, with no copy and no move. A non-copyable type
can still be returned by value. But elision is not a rule about your type being copyable; it is
a rule about which calls the compiler is allowed to skip. Write a copy and you get a copy.
And the one spelling that is worse than the obvious one:
warning: moving a local object in a return statement prevents copy elision
[-Wpessimizing-move]
note: remove 'std::move' call
return std::move(local); forbids the elision the compiler would otherwise perform, buying a
move where nothing was needed. return local; is the correct form. This is a real GCC
diagnostic, and it is on by default with -Wall.
10. What does the compiler still do for me?¶
Everything it is allowed to, which is more than you might expect and less than you might hope:
- It generates the special members it can, member-wise, and its choices are readable from section 3's table. Nothing there is magic; all of it is a rule.
= defaultasks for the generated version explicitly. That is usually better than writing it out, because a hand-written member-wise copy can silently miss a member added later.= deleteremoves one deliberately and produces a clear error at the call site.- It will not warn you about a shallow copy of an owning pointer, a self-assignment bug, a
missing
noexcept, aconstmember that prevents a move, or a move that silently became a copy. Every one of those is invisible in the source and measurable only in behaviour — which is why this module's technique is counting, not reading.
Checkpoint¶
Answer these before moving to the worked examples. If any answer is a guess, re-read the section named.
- A class has a
std::stringmember, astd::vector<int>member, and adouble. Which special member functions must it declare, and why? (section 9) Buffer b = a;andb = a;both appear in the same function. Which special member function does each invoke, and which one has to release something? (sections 4 and 5)a = a;crashes in a class whose assignment operator deletes before it allocates. Explain the failure in one sentence, and give two fixes. (section 5)- What does
std::move(x)do, and what else must be true for a move actually to happen? (section 7) - A
std::vector<Item>copies every element when it grows. Give the most likely cause and the one-line fix. (section 8) - You add
~Widget();to a class that previously declared nothing. What did that single line quietly remove from the class's interface, and what will users notice? (section 3)
Next: Worked examples — six programs, each asking for a prediction before it shows you anything.