Effective Modern C++, Chapter 4

Smart Pointers

Raw pointers are powerful but dangerous. Smart pointers give you the power without the pain: automatic lifetime management, no leaks, no double-frees.

Prerequisites: Chapters 1-3. Familiarity with new/delete and RAII.
8
Chapters
4+
Simulations

Chapter 0: Why Smart Pointers?

Raw pointers have an exhausting list of problems: their declaration doesn't say whether they own the thing they point to, whether you should delete them or call some other destruction mechanism, whether to use delete or delete[], whether the pointer is even valid, and whether it's been deleted already.

Smart pointers fix all of these problems. They are wrappers around raw pointers that manage lifetime automatically and make ownership semantics explicit in the type system.

The big three: C++11 gives us std::unique_ptr (exclusive ownership), std::shared_ptr (shared ownership with reference counting), and std::weak_ptr (non-owning observer of shared_ptr). Each encodes a different ownership policy directly in the type.
Smart Pointer Landscape

Three smart pointer types, three ownership models.

What is the fundamental problem with raw pointers that smart pointers solve?

Chapter 1: std::unique_ptr

std::unique_ptr embodies exclusive ownership. It's the same size as a raw pointer, executes the same instructions for dereferencing, and should be your default smart pointer. When a unique_ptr is destroyed, it deletes the resource it owns.

Moving a unique_ptr transfers ownership. Copying is not allowed — if you could copy it, two unique_ptrs would think they each own the resource.

c++
// Factory function returning unique_ptr (Item 18 pattern)
class Investment { public: virtual ~Investment(); };
class Stock : public Investment { };
class Bond  : public Investment { };

template<typename... Ts>
std::unique_ptr<Investment> makeInvestment(Ts&&... params) {
    // create the right derived type, return as base unique_ptr
    auto deleter = [](auto* p) { log(p); delete p; };
    // ... (factory logic)
}

auto pInv = makeInvestment(/* args */);
// pInv destroyed here: Investment is automatically deleted
Custom deleters: unique_ptr accepts a custom deleter as a second template parameter. But beware: a custom deleter increases the unique_ptr's size. Stateless lambdas (no captures) add zero size. Function pointers add one pointer. std::function objects can add much more.

A key feature: unique_ptr easily converts to shared_ptr, making it ideal for factory return types. Callers get exclusive ownership by default but can upgrade to shared ownership if needed.

Why is unique_ptr the ideal return type for factory functions?

Chapter 2: std::shared_ptr

shared_ptr provides shared ownership through reference counting. When the last shared_ptr to a resource is destroyed, the resource is deleted. The cost? A shared_ptr is twice the size of a raw pointer (it stores a pointer to the resource and a pointer to the control block).

shared_ptr Memory Layout

Each shared_ptr points to both the resource and a control block with the reference count.

Control block rules: A control block is created when: (1) std::make_shared is called, (2) a unique_ptr is converted to shared_ptr, or (3) a shared_ptr is constructed from a raw pointer. This last case is dangerous: constructing two shared_ptrs from the same raw pointer creates two control blocks — double-free!
c++
// DANGER: two control blocks for the same object!
auto pw = new Widget;
std::shared_ptr<Widget> spw1(pw, loggingDel);
std::shared_ptr<Widget> spw2(pw, loggingDel);  // double-free!
Propertyunique_ptrshared_ptr
SizeSame as raw pointer2x raw pointer
OwnershipExclusiveShared (ref-counted)
CopyableNo (move only)Yes
Custom deleterPart of typeNot part of type
OverheadZeroControl block + atomic ref count
What happens if you construct two shared_ptrs from the same raw pointer?

Chapter 3: std::weak_ptr

Sometimes you need a pointer that acts like a shared_ptr but doesn't participate in ownership. weak_ptr is that pointer: it can observe a shared_ptr-managed resource without affecting its reference count.

c++
auto spw = std::make_shared<Widget>();  // ref count = 1
std::weak_ptr<Widget> wpw(spw);         // ref count still 1

spw = nullptr;  // ref count = 0, Widget destroyed

if (wpw.expired()) { /* Widget is gone */ }

// Safe access: lock() returns shared_ptr (null if expired)
auto spw2 = wpw.lock();
if (spw2) { /* use *spw2 safely */ }
Use cases: (1) Caching — a cache can hold weak_ptrs to previously computed results; if the result is still alive, reuse it. (2) Observer pattern — subjects hold weak_ptrs to observers so they don't prevent observer destruction. (3) Breaking cycles — in a parent-child relationship where both sides use shared_ptr, one side should use weak_ptr to break the reference cycle.
c++
// Caching factory pattern
std::shared_ptr<Widget> fastLoadWidget(WidgetID id) {
    static std::unordered_map<WidgetID, std::weak_ptr<Widget>> cache;
    auto sp = cache[id].lock();  // try to get existing
    if (!sp) {
        sp = loadWidget(id);     // not cached: load it
        cache[id] = sp;            // cache a weak_ptr
    }
    return sp;
}
How does weak_ptr break reference cycles between shared_ptrs?

Chapter 4: Prefer make Functions

std::make_shared and std::make_unique (C++14) should be preferred over direct use of new for three reasons:

c++
// Direct new: type name written twice
auto upw1(std::unique_ptr<Widget>(new Widget));
auto spw1(std::shared_ptr<Widget>(new Widget));

// make functions: type name once, exception safe
auto upw2 = std::make_unique<Widget>();
auto spw2 = std::make_shared<Widget>();
Exception safety: In processWidget(shared_ptr<Widget>(new Widget), computePriority()), the compiler may order operations as: (1) new Widget, (2) computePriority(), (3) shared_ptr constructor. If step 2 throws, the Widget from step 1 leaks. make_shared eliminates this risk.
Performance bonus for make_shared: make_shared allocates the object and control block in a single allocation (one call to the allocator). Direct new + shared_ptr constructor requires two allocations.

When you can't use make functions: (1) custom deleters (make functions don't support them), (2) braced initializers (make functions use parentheses internally), (3) classes with custom operator new/delete, and (4) when weak_ptrs outlive shared_ptrs (make_shared's single allocation means the memory isn't freed until the last weak_ptr dies).

Why is make_shared more efficient than shared_ptr(new T)?

Chapter 5: The Pimpl Idiom

The Pimpl (Pointer to IMPLementation) idiom hides a class's data members behind a pointer to a forward-declared implementation struct. This reduces compilation dependencies — changes to the implementation don't force recompilation of clients.

widget.h
class Widget {
public:
    Widget();
    ~Widget();                          // must be declared!
    Widget(Widget&&);                  // must be declared!
    Widget& operator=(Widget&&);       // must be declared!
private:
    struct Impl;                       // forward declaration only
    std::unique_ptr<Impl> pImpl;       // pointer to incomplete type
};
widget.cpp
struct Widget::Impl {
    std::string name;
    std::vector<Gadget> gadgets;
    // all the heavy headers stay here
};

Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;          // defined where Impl is complete
Widget::Widget(Widget&&) = default;
Widget& Widget::operator=(Widget&&) = default;
The critical rule: The destructor, move constructor, and move assignment operator must be declared in the header and defined in the .cpp file where Impl is complete. The compiler-generated versions try to delete/move the unique_ptr<Impl>, which needs to see Impl's definition. If you only declare them in the header and = default them in the .cpp, it works perfectly.

With shared_ptr, this isn't necessary — the deleter isn't part of the type, so the pointed-to type doesn't need to be complete at the point of destruction. But unique_ptr is the right choice here (exclusive ownership), so you pay the small cost of declaring special members.

Why must the destructor be defined in the .cpp file when using Pimpl with unique_ptr?

Chapter 6: Ownership Decision Lab

Which smart pointer should you reach for? Click each scenario to see the recommendation.

Choose Your Smart Pointer

Click a scenario to see which smart pointer fits.

Chapter 7: Beyond

ItemKey Takeaway
Item 18Use unique_ptr for exclusive ownership. Zero overhead. Converts to shared_ptr.
Item 19Use shared_ptr for shared ownership. 2x pointer size, atomic ref count, control block.
Item 20Use weak_ptr to observe shared_ptr without owning. Caches, observers, cycle-breaking.
Item 21Prefer make_shared/make_unique: exception safe, fewer allocations, less code.
Item 22Pimpl with unique_ptr: declare special members in header, define in .cpp.
Next: Chapter 5: Rvalue References, Move Semantics, and Perfect Forwarding dives into the engine that makes smart pointers (and everything else in modern C++) fast.

"When you reach for a smart pointer, std::unique_ptr should generally be the one closest at hand." — Scott Meyers