Module 11 — Concepts and Explanations¶
Module 10 ended with a rule: the best way to obey the Rule of Three is to own nothing yourself. This page supplies the members that make that possible, and names the idea behind all of them.
RAII — Resource Acquisition Is Initialisation — every resource is acquired in a constructor and released in a destructor. Because C++ guarantees that destructors run when a scope ends, including when the scope ends because of an exception, release stops being something you remember and becomes something the language does.
Then the vocabulary: unique_ptr for exclusive ownership, shared_ptr for genuinely shared
ownership, weak_ptr for observing without owning. That vocabulary is how you answer this
module's one question — who owns this? — in a type, where the compiler can check it.
1. How does std::string free its memory with no destructor I can see?¶
It has one; you just did not write it. Watch it announce itself:
{
std::string name = "Ayesha";
std::cout << name.size() << "\n";
} // <- std::string's destructor runs here, and the characters are freed
You have relied on this since Module 2, and on std::vector since Module 9. Neither type has
ever leaked, on any exit path, including paths where an exception is thrown. That is RAII, and
the point of this section is to make it explicit rather than incidental:
constructor acquires the resource (allocates the characters)
destructor releases it (frees the characters)
nothing else you never call a release function, because there is none to forget
What this is not: a memory technique. RAII is a technique for anything with a release step — a file, a lock, a socket, a database connection, a device handle. The rest of this page applies it to all of them.
2. I must not forget to close this file.¶
Then do not remember it. Put it in an object:
class FileGuard {
public:
explicit FileGuard(const std::string& path)
: file_(std::fopen(path.c_str(), "a")) {
if (file_ == nullptr) {
throw std::runtime_error("cannot open " + path); // nothing to release: no object
}
}
~FileGuard() noexcept {
if (file_ != nullptr) {
std::fclose(file_); // release, on every path, exactly once
}
}
FileGuard(const FileGuard&) = delete; // Module 10: no shallow copies
FileGuard& operator=(const FileGuard&) = delete;
void write(const std::string& line) { std::fputs(line.c_str(), file_); }
private:
std::FILE* file_;
};
That is the whole class — about fifteen lines — and it removes an entire category of bug. There are exactly three ways out of the function that uses it:
1. normal return destructor runs
2. an early return from the middle destructor runs
3. an exception propagating outward destructor runs <- the path people forget
examples/module-11/01-raii-across-exit-paths/main.cpp measures all three, with a counter in
place of a real device, and the result is blunt: the manual version handled one path out of
three and leaked on the other two, while the guard balanced the books on all three with no release
statement anywhere. The leak from the manual version is unrecoverable — the handle was a local
variable and is gone — which is why "I will close it at the end" is not a strategy.
Two details that belong with the wrapper:
- A destructor must not throw. If
fclosecould fail and you threw from~FileGuard(), a second exception during unwinding would terminate the program. Log, swallow, or provide an explicitclose()that callers may call if they need to see the failure. - A constructor that throws has not constructed anything. The acquisition failure in
FileGuardthrows before any resource exists, so there is nothing to release and no destructor runs. RAII is correct at the failure point too — see Part 2 of the example program.
3. Who owns this pointer?¶
Look at this signature and answer without reading the body:
Does it own? Borrow? Delete? Keep a copy of the pointer? The signature does not say, and neither does the compiler. The two possible bodies look identical at the call site, and a caller who guesses wrong either leaks or double-frees. That is the entire problem this module solves.
void process(Book* book); may be null, does NOT own -- by convention only
void process(Book& book); must outlive the call, does NOT own -- by convention
std::unique_ptr<Book> process(Book&& book); takes ownership -- in the TYPE
The third signature is checked by the compiler. The first two are checked by discipline, which is a weaker guarantee but a much smaller cost, and it is the right tool when no ownership is being transferred.
4. What if exactly one thing owns it?¶
Then say so with std::unique_ptr<T>: exclusive ownership, enforced by the type.
std::unique_ptr<Shape> owner = std::make_unique<Circle>(2.0); // C++14: no `new` in sight
// std::unique_ptr<Shape> copy = owner; // does not compile: deleted copy
std::unique_ptr<Shape> second = std::move(owner); // ownership transferred; owner is now null
Why the copy is deleted is the whole design: a copy would create a second owner, and two owners delete the same object once each — Module 10's double free, this time preventable at compile time instead of diagnosable at run time.
- Cost: none measurable.
sizeof(std::unique_ptr<T>) == sizeof(T*), and the operations compile to the same code a raw pointer would.examples/module-11/03-unique-ownershipprints both sizes, then assertsstd::is_copy_constructible<...>::value == false. make_uniquerather thannew: it cannot leak if a later argument throws, and it removes the wordnewfrom your code. The same argument applies tomake_shared.- Transferring ownership is a move.
std::moveis a cast — Module 10 again — and after it the source is null, not dangling. That is the improvement onauto_ptr, which copied and nulled the source silently;auto_ptrwas removed from the standard in C++17.
5. How do I pass it to a function that does not own it?¶
Do not pass the owning pointer at all. Pass what the callee actually needs:
double area(const Shape& shape); // borrow: cannot be null, cannot be re-seated
void print(const Shape* shape); // borrow: may be null, clearly optional
void registerShape(std::unique_ptr<Shape> shape); // OWN: the ownership moves in
Each signature makes a different promise, and readers can tell them apart. Two rules that follow:
unique_ptr<T>by value means "I am taking this". If the callee only reads, that signature is a lie with a price: the caller must move, and can no longer use the object.- Most
std::movecalls in beginner code are unnecessary, and some are harmful. PassT&for a read, and reservestd::movefor a real transfer. Part B ofexamples/module-11/03-unique-ownership/main.cppruns all three signatures on the same object and shows the ownership moving in exactly one of them.
6. What if several things genuinely own it?¶
Sometimes no single owner can be named: a cache with several valid entry points, a configuration
object read by many components, a shared graph node. Then use std::shared_ptr<T>, which shares
ownership through a reference count.
std::shared_ptr<Config> config = std::make_shared<Config>(...);
std::shared_ptr<Config> also = config; // copyable on purpose: use_count is now 2
std::cout << config.use_count(); // 2
The object is destroyed when the last owner goes away, and the destructor runs exactly once —
Part A of examples/module-11/04-shared-and-weak/main.cpp prints the count at each release to show
it.
What the count costs and what it does not do:
- The count lives in a separate control block, alongside the deleter. A
shared_ptris therefore two pointers wide, not one. make_sharedusually performs one allocation for the object and the control block together;shared_ptr<T>(new T)performs two.- The reference count is atomic, so incrementing it from two threads is safe. The pointed-to
object is not protected at all.
shared_ptris not a mutex, and "thread-safe count" is the most over-read sentence in C++. shared_ptris not the default. If one thing owns it,unique_ptris cheaper, simpler and cannot create the cycle in the next section. "Shared" is a claim about the design, and it should be a true one.
7. Why are my objects never freed?¶
Because they own each other:
struct Node {
std::shared_ptr<Node> next; // owning link in both directions
~Node() { std::cout << "node destroyed\n"; }
};
auto first = std::make_shared<Node>();
auto second = std::make_shared<Node>();
first->next = second; // second's count is now 2
second->next = first; // first's count is now 2
// scope ends: the two locals die, each count falls to 1 -- and stops there
No destructor runs. Neither count can reach zero, because each object is kept alive by the other. This is a reference cycle, and it is a leak that behaves unlike the leak you met in Module 10: nothing is unreachable, no pointer is dangling, and a leak checker reports it as still reachable rather than definitely lost. There is no line of code to point at.
The fix is to make one direction of the relationship non-owning:
struct Node {
std::shared_ptr<Node> next; // owns the next node
std::weak_ptr<Node> previous; // observes; does NOT keep alive
};
A std::weak_ptr<T> refers to an object owned by shared_ptr without adding to the count:
expired()— is the object gone? (trueonce the last owner has died)lock()— give me a temporary owner if it still exists, otherwise an emptyshared_ptr.lock()is the safe way to use the object: it returns an owner, so the object cannot die underneath you while you use the result.use_count()— the number of owners, for diagnostics.
Do not use expired() and then use the object anyway: between the check and the use, the last
owner may go away. lock(), check it, and use the locked pointer. Part C of
examples/module-11/04-shared-and-weak/main.cpp builds the same three-node graph twice — once
with shared back-links, once with weak ones — and the difference is the three destructor lines
that appear only in the second version.
8. Why did this crash with two control blocks?¶
Because a shared_ptr built from a raw pointer creates its own control block, and doing that
twice for one object gives you two counts and two deleters for it:
Node* raw = new Node(...);
std::shared_ptr<Node> first(raw);
std::shared_ptr<Node> second(raw); // compiles! two control blocks, two deletes
Both report use_count() == 1. Each believes it is the only owner. Each will delete the same
object. examples/module-11/04-shared-and-weak Part D demonstrates it with a guard in place so
the program survives long enough to explain what happened.
The rules that follow, in order of importance:
- Never write
std::shared_ptr<T> p(new T). Writestd::make_shared<T>(). The factory makes one control block, and it is exception-safe. - If an object must hand out
shared_ptrs to itself, it must inheritstd::enable_shared_from_this<T>and returnshared_from_this()rather than constructing ashared_ptrfromthis. - A factory that returns
shared_ptrmust returnshared_ptr— not a raw pointer for the caller to wrap. Handing out raw pointers to a shared object is how the two-control-block bug reaches production.
9. What about polymorphic collections?¶
unique_ptr and inheritance meet in the container that Lab 7 and Lab 8 wanted: a collection of
base-owning pointers that holds several kinds and cleans them all up.
class Drawing {
public:
void add(std::unique_ptr<Shape> shape) { shapes_.push_back(std::move(shape)); }
double totalArea() const {
double total = 0;
for (const std::unique_ptr<Shape>& shape : shapes_) { total += shape->area(); }
return total;
}
private:
std::vector<std::unique_ptr<Shape>> shapes_; // no destructor, no copy, nothing written
};
Three facts about that five-line class:
- No destructor is written, and nothing leaks. When the
Drawingdies, the vector destroys eachunique_ptr, and eachunique_ptrdeletes its shape. That is the Rule of Zero achieved. - The base destructor must be
virtual— Module 7's requirement, and here it is load-bearing: without it,deletethroughShape*does not runCircle's destructor, and the behaviour is undefined. std::vector<Shape>would not work at all. It would slice: avector<Base>storesBaseobjects and cannot hold aCircle. The container is of pointers, and the pointers are owning pointers.
10. How do I refactor existing raw pointers?¶
Mechanically, one declaration at a time, and each step forces a decision the raw pointer was hiding:
| Raw form | Ask | Then write |
|---|---|---|
T* member plus delete in the destructor |
Does anything else refer to it? | std::unique_ptr<T>; delete the destructor |
T* member, never deleted |
Who keeps it alive? | T&, T* (non-owning), or std::weak_ptr<T> if it must be checked |
T* parameter, callee stores it |
Does the callee now own it? | std::unique_ptr<T> by value (transfer) or std::shared_ptr<T> (share) |
T* parameter, used for the call |
— | T& (must exist) or T* (may be null) |
new T returned to a caller |
Who deletes it? | Return std::unique_ptr<T> and let the type answer |
std::vector<T*> |
Who owns the elements? | std::vector<std::unique_ptr<T>>, or std::vector<T> if values suffice |
Two habits make the conversion safe. Delete the destructor after the last raw resource is gone — if you cannot delete it, something is still owned by hand. And count: put counters in whatever owns the resource and prove they return to zero on both the normal and the throwing path, which is the only evidence that the refactor did what you think it did. That is Lab 11.
Checkpoint¶
- State RAII in one sentence without using the word memory. (section 2)
- Why does RAII matter more once exceptions exist? (section 2)
- What does each smart pointer express, and which is the default? (sections 4 to 6)
- Why can a
unique_ptrnot be copied, and what replaces the copy? (section 4) - A function takes
std::shared_ptr<T>by value but never stores it. What does that cost, and what should the signature be? (sections 5 and 6) - Two
shared_ptrs own each other. Predict the destructor output, and say why no sanitizer reports it. (section 7) use_count()prints1twice for one object. What has gone wrong, and what are the two fixes? (section 8)
Next: Worked examples — six programs, each asking for a prediction before it shows you anything.