Skip to content
VLSI Mentor

C++ Part I · Module 14 · Smart Pointers and Ownership

Designing Ownership-Aware Interfaces

Seven chapters have each produced a rule about one type, and the rules now collide often enough to need adjudicating. A function that takes a model: by value, by reference, by raw pointer, by unique_ptr, by shared_ptr, by const shared_ptr reference? A class that holds one: as a value member, a unique_ptr, a shared_ptr, a raw pointer, a weak_ptr? Six or seven defensible answers in each case and usually one correct one — and the useful discovery is that the right answer is almost always forced, not chosen, once you write down two things: who owns the object, and whether the callee's use of it outlives the call. This chapter is that decision procedure, as two tables you can apply without re-deriving the module, plus the two things it needs that no earlier chapter covered. The first is the measured cost of the mistake that is made more often than all the others combined — taking a shared_ptr by value in a function that only reads the object — which this chapter times at four point seven times the correct signature, in a harness whose first version was broken in a way worth showing you. The second is the one case where a class legitimately needs a shared_ptr to itself, along with what happens when you build it the obvious way: two control blocks that each believe they are the only owner, both reporting a count of one, and a process that exits 133.

Advanced21 min readC++Part IFundamentalsOwnership

Part I · Module 14 · Chapter 14.8 · Designing Ownership-Aware Interfaces

1. Two Questions Decide Every Signature

Not preference, not a house style. Two facts about what the function is for:

  1. Does the function take ownership? After it returns, is it responsible for destroying the object?
  2. Does its use of the object outlive the call? Does it store the pointer, or only use it while running?

Four combinations, and each has one answer:

Takes ownership?Use outlives call?Parameter
nonoconst T&, or T& to modify, or const T* if absent is meaningful
yesyesstd::unique_ptr<T> by value
noyes (owner's lifetime unknown)std::weak_ptr<T>
yes, jointlyyesstd::shared_ptr<T> by value

The first row is by far the most common and the one most often got wrong. The fourth is the rarest and the one most often reached for.

2. Why the Questions Are the Right Ones

Because each has already been measured.

Question 1 is answered by the signature and enforced by the compiler. Chapter 14.1 §5 measured takesOwnership(u) being refused without std::move, and Chapter 14.3 §5 measured the object destroyed inside the callee with the caller's pointer at 0x0. A by-value unique_ptr parameter means you now own this, and it is checked at every call site.

Question 2 is Chapter 14.7 §7's discriminator. If the use does not outlive the call, the owner is in the caller's frame for the whole call, so a raw pointer or reference is a statement of fact rather than a risk. If it does, and you cannot say who dies first, that is what a weak_ptr is for.

What makes the table usable is that both questions have answers in the design. Chapter 14.7's callout named the failure mode of not asking: picking the type that seems not to require knowing.

3. The Cost of Getting Row One Wrong

The common mistake is not a crash. It is writing this —

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    void logModel(std::shared_ptr<Model> m);          // observes, takes by value

— for a function that only reads. Fifty million calls, three signatures, -O2, three runs:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
                                    run 1     run 2     run 3
const Model&                       50.5 ms   50.5 ms   49.7 ms
const std::shared_ptr<Model>&      50.3 ms   50.4 ms   49.8 ms
std::shared_ptr<Model> by value   234.8 ms  233.0 ms  240.0 ms

const shared_ptr& / const Model&     1.00x     1.00x     1.00x
by-value shared_ptr / const Model&   4.65x     4.62x     4.83x

Two results.

By const reference costs exactly nothing — 1.00x on every run. Chapter 14.7 §4 measured why: no refcount traffic occurs, because nothing was copied.

By value costs about 4.7x, from Chapter 14.5 §5's two atomic read-modify-writes per call, paid to acquire ownership the function does not want and does not use.

And this is a floor, not a worst case. The measurement is single-threaded and uncontended — one thread touching one cache line. Under real contention, several cores modifying the same counter, the same two atomics cost substantially more. Module 26 is where that is a subject.

4. How That Number Was Nearly Wrong

Worth showing, because the failure is invisible in the output.

My first harness had no compiler barrier. It reported:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    const Model&                            0.0 ms
    const std::shared_ptr<M>&               0.0 ms
    std::shared_ptr<M>  by value          374.9 ms

    by-value / const-ref ratio : 9144797.8x

0.0 ms and a ratio of nine million. The functions were marked noinline, which I had assumed was enough. It was not: the compiler could see that byConstRef(*p) returns the same value every iteration and hoisted the entire loop out, leaving nothing to time. The by-value loop survived only because refcount traffic is an observable side effect the compiler is not allowed to remove — so the one case I was trying to indict was the only one actually measured.

The fix was a barrier the optimiser cannot see through:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    static inline void sink(int x) { asm volatile("" :: "r"(x) : "memory"); }

The general rule is worth more than the number it rescued: a timing harness whose fast case reads zero has measured nothing. An absurd ratio is the lucky version of this bug, because it is obviously wrong. A plausible one — 1.8x instead of 4.7x — would have been published. Chapter 14.6 §11 made the same argument about a silent tool: confirm the instrument can register the thing before believing what it says.

5. Return Types

Same two questions, pointed the other way: does the caller become an owner?

IntentReturn
caller owns the resultstd::unique_ptr<T> — free, Chapter 13.12 §3
caller co-owns itstd::shared_ptr<T>
caller observes something you keepconst T&, or T* if it can be absent
caller observes, and you may release it firststd::weak_ptr<T>
the value is small and copying is the pointT by value

Two rules on top of the table.

Never return a raw pointer the caller must delete. Chapter 10.9 §6 measured that the contract can only live in a comment; unique_ptr puts it in the type.

And never return a shared_ptr to something you own exclusively. It silently lets callers extend the lifetime past your own, and Chapter 14.6 §10 showed where the cycles come from — a non-ownership relationship spelled as ownership.

An accessor that moves out of a member is a transfer, not a read, and should be named take, release or extract (Chapter 13.9 §9 and Chapter 14.3 §9).

6. Members

The class…Member
owns it exclusively, size known, no polymorphismT by value — cheapest, Chapter 14.1 §9
owns it exclusively, polymorphic or large or transferredstd::unique_ptr<T>
co-owns it with others whose order is unknownstd::shared_ptr<T>
observes something guaranteed to outlive itT* — Chapter 14.7 §7
observes something that may die firststd::weak_ptr<T>

The first row is the one people skip. Chapter 14.1 §9 measured vector<Model> — plain values — releasing correctly with a 4-byte element where the pointer forms paid 8 plus an allocation each. A unique_ptr member you never move and never make polymorphic is an allocation and an indirection bought for nothing.

And the fourth and fifth rows are where Chapter 14.6's cycles are prevented or created. A back-pointer is row four or five, never row three.

7. When a Class Needs a shared_ptr to Itself

The one genuinely new case in this chapter, promised by Chapter 14.5 §2. An object sometimes has to hand out a shared_ptr to itself — registering itself as an observer, or scheduling a callback that must keep it alive.

The obvious implementation is wrong:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    std::shared_ptr<Bad> self() { return std::shared_ptr<Bad>(this); }     // WRONG
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
B. shared_ptr<Bad>(this) (the trap):
    + Bad
    owner.use_count()=1
    after self(): owner.use_count()=1  s.use_count()=1  same object? 1
    leaving the scope -- both will destroy it...
    - Bad
exit: 133

Read the third line. same object? 1 — one object. And both counts say 1. The constructor had no way to find the existing control block, so it made a second one, and now two independent counters each believe they are the only owner of the same object. Chapter 13.4 §9's question — how many objects can reach it, and how many will release it — answers one and two.

Leaving the scope, both reach zero and both destroy it. One destructor line printed, then the process died: exit 133, and the live= line never printed at all. Under AddressSanitizer:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ERROR: AddressSanitizer: attempting free on address which was not malloc()-ed
SUMMARY: AddressSanitizer: bad-free unique_ptr.h:77 in default_delete<Bad>::operator()(Bad*) const

not malloc()-ed rather than a plain double-free, for the same reason as Chapter 14.4 §10's class-array mismatch: make_shared placed the object inside a combined block (Chapter 14.5 §6), so delete this hands the allocator an address it never issued.

8. enable_shared_from_this

The standard's answer. Derive from it, and call shared_from_this():

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct Good : std::enable_shared_from_this<Good> {
        std::shared_ptr<Good> self() { return shared_from_this(); }
    };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A. enable_shared_from_this (correct):
    + Good
    owner.use_count()=1
    after self(): use_count=2, same object? 1
    - Good
    live=0

use_count=2, one count, one control block. The base class holds a weak_ptr that the owning shared_ptr's constructor fills in, so shared_from_this() finds the existing control block and adds to it rather than inventing a rival. The object was destroyed once and live=0.

Two conditions, because they are how this goes wrong in practice:

The object must already be owned by a shared_ptr. Both violations are measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A. shared_from_this() on a STACK object:
    threw std::bad_weak_ptr (bad_weak_ptr)
B. shared_from_this() inside the CONSTRUCTOR:
    ctor: threw std::bad_weak_ptr (bad_weak_ptr)
C. the correct case, for contrast:
    use_count=2 same=1

In both failing cases the base class's internal weak_ptr has not been filled in — on the stack it never will be, and inside the constructor the owning shared_ptr does not exist yet because the object is not finished. So there is no control block to find, and the call throws rather than guessing. Note that B is the one that catches people: the object will be owned by a shared_ptr a moment later, but not yet. Chapter 14.2 §10 measured the analogous unique_ptr failure on a stack address, and Chapter 12.8 §3's construction order is why the constructor case cannot work.

And it only makes sense if shared ownership is the right model at all. enable_shared_from_this is the fix for a specific mechanical problem inside a design that already passed §1's table. It is not a reason to adopt shared_ptr.

9. Reading an Unfamiliar Interface

The tables run backwards too, which is most of their day-to-day value. Given a header you did not write:

You seeIt means
void f(std::unique_ptr<T>)f takes ownership — you must std::move, and your pointer will be null
std::unique_ptr<T> g()you own the result
void f(const T&) / void f(T*)f only looks — your ownership is untouched
void f(std::shared_ptr<T>)f becomes a co-owner — or the author got row one wrong
void f(const std::shared_ptr<T>&)f observes, but the signature says "ownership" needlessly
std::weak_ptr<T> h()the object may already be gone — lock() and test
T* h()you observe; you must not delete it

The fourth row is the honest one: a by-value shared_ptr parameter is ambiguous in practice, because it is both the correct spelling for joint ownership and the most common mistake. Chapter 14.3 §2's reading — a by-value unique_ptr means you now own this — has no such ambiguity, which is a reason to prefer exclusive ownership wherever it fits.

10. What to Actually Do

Ask the two questions before writing any signature that involves an object someone owns.

Default to const T& for observers — measured free. Use T* only when absence is meaningful.

Never take a smart pointer by value to observe — measured 4.7x, and a floor.

Take unique_ptr<T> by value to take ownership; return it to hand ownership out.

Prefer a plain value member where the class owns one exclusively and nothing is polymorphic or transferred.

Make every back-pointer a T* or weak_ptr, never a shared_ptr.

Use enable_shared_from_this — never shared_ptr<T>(this).

Name transferring functions take, not get.

And read other people's signatures with §9's table, remembering that a by-value shared_ptr is ambiguous in practice.

11. Mistakes Worth Making Once

void f(std::shared_ptr<T> p) for an observer. Measured 4.7x for ownership the function never uses.

void f(const std::shared_ptr<T>&) for an observer. Free, but the signature claims ownership is relevant when it is not.

A unique_ptr member that is never moved and never polymorphic. An allocation and an indirection for nothing.

Returning a shared_ptr to something you own exclusively. Callers can outlive you; cycles follow.

Returning a raw pointer the caller must delete. The contract lives in a comment.

shared_ptr<T>(this). Measured: two control blocks, both counts 1, bad-free, exit 133.

shared_from_this() in a constructor or on a stack object. std::bad_weak_ptr.

A shared_ptr back-pointer. Chapter 14.6's cycle.

An accessor named get that moves out of a member.

Trusting a timing harness whose fast case reads 0.0 ms.

12. Interview Relevance

"How do you decide what a function should take?" The two questions — ownership, and whether the use outlives the call. Naming the procedure rather than reciting the types is the strong answer.

"What is wrong with void f(std::shared_ptr<T> p) for a function that only reads?" Two atomics per call for ownership it discards — measured 4.7x — and a signature that misstates intent. const T& is correct.

"Then is const shared_ptr<T>& right?" Free, but still wrong: it says ownership matters when it does not, and it needlessly forbids callers holding the object another way.

"When would a class hold unique_ptr<T> rather than T?" Polymorphism, large or transferred objects, stable addresses. Otherwise a value member.

"How does an object get a shared_ptr to itself?" enable_shared_from_this. shared_ptr<T>(this) makes a second control block — measured as both counts reading 1, then bad-free and exit 133. A strong discriminator.

"What does shared_from_this() require?" That a shared_ptr already owns the object; otherwise std::bad_weak_ptr.

"How do you read an unfamiliar header?" §9's table — and note that a by-value shared_ptr is genuinely ambiguous between correct joint ownership and the common mistake.

13. What You Should Be Able to Say Now

  1. Two questions decide every signature: does it take ownership, and does its use outlive the call?
  2. The four combinations give const T&, unique_ptr<T> by value, weak_ptr<T>, and shared_ptr<T> by value.
  3. Row one is the commonest and most often wrong; row four the rarest and most often reached for.
  4. Measured: const shared_ptr& costs 1.00x — no refcount traffic, nothing copied.
  5. Measured: a by-value shared_ptr observer costs 4.6–4.8x across three runs.
  6. That is a floor — single-threaded and uncontended.
  7. My first harness reported 0.0 ms and a ratio of nine million because the const-ref loops were hoisted away; only the by-value loop survived, since refcount traffic is a side effect.
  8. A timing harness whose fast case reads zero has measured nothing — and a plausible wrong ratio would have shipped.
  9. Return unique_ptr to hand ownership out — free (13.12 §3) — and never a raw pointer the caller must delete.
  10. Never return a shared_ptr to something you own exclusively.
  11. Prefer a plain value member unless polymorphism, size, transfer or address stability requires otherwise.
  12. Every back-pointer is T* or weak_ptr, never shared_ptr.
  13. Measured: shared_ptr<T>(this) gave two control blocks, both counts 1, then bad-free and exit 133.
  14. not malloc()-ed, because make_shared put the object inside a combined block — the same offset-pointer family as 14.4 §10.
  15. enable_shared_from_this gave use_count=2, one control block, live=0 — and requires an existing owning shared_ptr.
  16. A by-value shared_ptr parameter is ambiguous in practice, which is a reason to prefer exclusive ownership wherever it fits.

14. Exercises

Exercise 1 — the procedure. State the two questions and give the parameter type for each of the four combinations.

Exercise 2 — fix the header. Correct each: void log(std::shared_ptr<const Model> m); (reads only); Model* makeModel(); (caller owns); std::shared_ptr<Model> Sim::model() const; (the simulation owns it exclusively); void Registry::get(std::unique_ptr<Model>& out);.

Exercise 3 — the cost. Give the three measured timings and both ratios, and say why const shared_ptr& is 1.00x.

Exercise 4 — the broken harness. Explain why the first version reported 0.0 ms for two of three cases and why the third survived, then say what sink fixes.

Exercise 5 — members. Choose the member type: a 4-byte Config the class owns; a polymorphic Strategy; a parent pointer in a child node; a cache entry's pointer to a model other code may release; a Logger shared with three subsystems.

Exercise 6 — the trap. Say what std::shared_ptr<T>(this) produces, what both use_count() values read, what the process does, and why the diagnostic says not malloc()-ed.

Exercise 7 — shared_from_this. Write the correct version, give the two conditions, and say what happens if either is violated.

Exercise 8 — read the header. For each of §9's seven rows, state your obligation as a caller in one sentence.

Exercise 9 — the uncomfortable table. Using Chapter 14.7 §6's two verdicts, explain to a colleague why "shared_ptr everywhere" is not a safety improvement.

Exercise 10 — design one. A Scoreboard is given transactions it keeps, reads a reference model it does not own, and hands finished reports to a reporter. Give all three signatures and justify each from the two questions.

15. Summary

  • Two questions decide every signature: ownership, and whether the use outlives the call. Everything else is a cached answer.
  • Measured: const shared_ptr& is 1.00x; a by-value shared_ptr observer is 4.6–4.8x — and that is a floor.
  • My first harness read 0.0 ms and nine million times — the fast loops were optimised away. A fast case of zero means nothing was measured.
  • Return unique_ptr to transfer, references or raw pointers to observe, never a deletable raw pointer and never a shared_ptr to something you own alone.
  • Prefer plain value members; use unique_ptr for polymorphism, size, transfer or address stability.
  • Back-pointers are T* or weak_ptr, never shared_ptr.
  • Measured: shared_ptr<T>(this) gave two control blocks, both counts 1, bad-free, exit 133; enable_shared_from_this gave use_count=2 and live=0.
  • Read signatures with §9's table, and remember a by-value shared_ptr is ambiguous between the correct and the commonest answer.
  • A signature is the only documentation re-checked at every call.

That closes Module 14, and with it the long arc that began in Module 10. Dynamic allocation gave you objects whose lifetime you controlled and then could not keep track of; Module 11 built an object able to make a promise; Module 12 supplied the guarantees that make a promise keepable and named the technique RAII; Module 13 made objects copyable and movable on purpose; and this module turned all of it into a small set of types that say who is responsible for what, in a language the compiler enforces. The objects you can now build own their resources correctly, transfer them explicitly, and state their ownership in their interfaces.

What they cannot yet do is behave like the built-in types. A Matrix you add with add(a, b) rather than a + b, a BitVector you compare with equals(x, y), a smart-pointer-like wrapper of your own that needs * and -> to mean something — Module 14 has been using operator*, operator->, operator[] and operator bool on standard types for eight chapters without ever showing how a type acquires them. Module 15 is Operator Overloading: seven chapters on giving your own types the operators that make them read like values, which members to overload and which to leave as free functions, and the small number of operators where the conventional signature is not obvious and getting it wrong produces code that compiles and surprises everyone.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.