Skip to content

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 fclose could fail and you threw from ~FileGuard(), a second exception during unwinding would terminate the program. Log, swallow, or provide an explicit close() that callers may call if they need to see the failure.
  • A constructor that throws has not constructed anything. The acquisition failure in FileGuard throws 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:

void process(Book* book);

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-ownership prints both sizes, then asserts std::is_copy_constructible<...>::value == false.
  • make_unique rather than new: it cannot leak if a later argument throws, and it removes the word new from your code. The same argument applies to make_shared.
  • Transferring ownership is a move. std::move is a cast — Module 10 again — and after it the source is null, not dangling. That is the improvement on auto_ptr, which copied and nulled the source silently; auto_ptr was 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::move calls in beginner code are unnecessary, and some are harmful. Pass T& for a read, and reserve std::move for a real transfer. Part B of examples/module-11/03-unique-ownership/main.cpp runs 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_ptr is therefore two pointers wide, not one.
  • make_shared usually 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_ptr is not a mutex, and "thread-safe count" is the most over-read sentence in C++.
  • shared_ptr is not the default. If one thing owns it, unique_ptr is 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? (true once the last owner has died)
  • lock() — give me a temporary owner if it still exists, otherwise an empty shared_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:

  1. Never write std::shared_ptr<T> p(new T). Write std::make_shared<T>(). The factory makes one control block, and it is exception-safe.
  2. If an object must hand out shared_ptrs to itself, it must inherit std::enable_shared_from_this<T> and return shared_from_this() rather than constructing a shared_ptr from this.
  3. A factory that returns shared_ptr must return shared_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 Drawing dies, the vector destroys each unique_ptr, and each unique_ptr deletes 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, delete through Shape* does not run Circle's destructor, and the behaviour is undefined.
  • std::vector<Shape> would not work at all. It would slice: a vector<Base> stores Base objects and cannot hold a Circle. 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

  1. State RAII in one sentence without using the word memory. (section 2)
  2. Why does RAII matter more once exceptions exist? (section 2)
  3. What does each smart pointer express, and which is the default? (sections 4 to 6)
  4. Why can a unique_ptr not be copied, and what replaces the copy? (section 4)
  5. 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)
  6. Two shared_ptrs own each other. Predict the destructor output, and say why no sanitizer reports it. (section 7)
  7. use_count() prints 1 twice 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.