Lesson 01 / 11
Smart Pointers
Naming what an operation leaves in the source after it runs on a unique_ptr, shared_ptr, or weak_ptr with four kinds: unchanged, changed, empty, unspecified.
Published Updated
In this lesson
- distinguishes the ownership difference among unique_ptr, shared_ptr, and weak_ptr
- explains the state a move operation leaves in the source using the guarantee the standard gives
- builds an example showing that a std::move call alone moves nothing
- writes a defined counterpart to a reference cycle leak using weak_ptr
Contents
The previous course measured which object an expression works with, and closed by noting that copying is not the only path: an operation can take over what the source holds instead of opening a new copy at its target. This handover is called a move. When a move finishes, the source object does not vanish — its destructor still runs — but what it holds afterward now depends on the operation. This course names what is left in the source after an operation with four kinds; this lesson introduces those four kinds through smart pointers.
Four Kinds and the Full Run of the Core
The three files below build the shared measurement tool for this course’s eleven lessons. leftover.hpp names the four kinds:
// leftover.hpp — a core that names what is left in the source after an operation
#ifndef LEFTOVER_HPP
#define LEFTOVER_HPP
#include <string>
enum class Left { unchanged, changed, empty, unspecified };
// Turns a before/after observation into a kind. Emptiness is checked before equality.
Left compare(bool stayed_same, bool emptied);
// Prints the operation name and the kind on one line, and bumps the kind counter.
void report(const std::string& op, Left left);
// An unspecified source is not looked at: it first gets a new value or is destroyed.
template <typename NewValue>
void unspecified(const std::string& op, NewValue new_value) {
new_value();
report(op, Left::unspecified);
}
// Prints the kind counts and the upper split.
void summary();
#endif
leftover.cpp ties the four kinds to a line format and a counter:
// leftover.cpp — classification, line format and counter
#include "leftover.hpp"
#include <cstdio>
namespace {
int counts[4] = {0, 0, 0, 0};
const char* const names[4] = {"unchanged", "changed", "empty", "unspecified"};
}
Left compare(bool stayed_same, bool emptied) {
if (emptied) return Left::empty;
return stayed_same ? Left::unchanged : Left::changed;
}
void report(const std::string& op, Left left) {
int i = static_cast<int>(left);
++counts[i];
std::printf("%-32s %s\n", op.c_str(), names[i]);
}
void summary() {
std::printf("kinds: unchanged %d, changed %d, empty %d, unspecified %d\n",
counts[0], counts[1], counts[2], counts[3]);
std::printf("state specified %d, not specified %d\n",
counts[0] + counts[1] + counts[2], counts[3]);
}
The rest measures eleven operations and this lesson adds its own smart pointer examples:
// left_behind.cpp — the core's full run plus a smart pointer deep dive
#include <algorithm>
#include <map>
#include <memory>
#include <ranges>
#include <string>
#include <utility>
#include <vector>
#include "leftover.hpp"
static void smart() {
auto p = std::make_unique<int>(7);
int* before = p.get();
auto q = std::move(p);
report("unique_ptr moved", compare(p.get() == before, p == nullptr));
}
static void moving() {
std::string s = "a text long enough to go past the small string buffer";
std::string t = std::move(s);
unspecified("std::string moved", [&] { s = "new value"; });
}
struct Record {
std::string name;
~Record() {}
};
static void rule_of_five() {
Record a{"the long name of a class with a written destructor"};
std::string before = a.name;
Record b = std::move(a);
report("std::move on class with dtor", compare(a.name == before, a.name.empty()));
}
class Handle {
public:
explicit Handle(int id) : id_(id) {}
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
Handle(Handle&& other) noexcept : id_(other.id_) { other.id_ = -1; }
Handle& operator=(Handle&&) = delete;
int id() const { return id_; }
private:
int id_;
};
static void non_copyable() {
Handle a(3);
int before = a.id();
[[maybe_unused]] Handle b = std::move(a);
report("own move constructor", compare(a.id() == before, a.id() == -1));
}
static void container() {
std::map<std::string, int> stock{{"apple", 4}};
auto before = stock;
int count = stock["pear"];
(void)count;
report("operator[] read on a map", compare(stock == before, stock.empty()));
}
static void iterator() {
std::vector<int> v{1, 2, 3, 4};
auto it = v.begin() + 1;
auto next_it = v.erase(it);
unspecified("erased iterator", [&] { it = next_it; });
}
static void algorithm() {
std::vector<int> v{5, 1, 4, 2};
auto before = v;
std::sort(v.begin(), v.end());
report("std::sort", compare(v == before, v.empty()));
}
static void erase_remove() {
std::vector<int> v{1, 2, 3, 2, 5, 2};
auto new_end = std::remove(v.begin(), v.end(), 2);
unspecified("tail after std::remove", [&] { v.erase(new_end, v.end()); });
}
static void lambda() {
int step = 0;
auto count_up = [step]() mutable { return ++step; };
count_up();
count_up();
report("mutable capture by value", compare(step == 0, false));
}
static void range_view() {
std::vector<int> v{1, 2, 3};
auto before = v;
int total = 0;
for (int n : v | std::views::transform([](int x) { return x * 10; })) total += n;
(void)total;
report("view over an lvalue", compare(v == before, v.empty()));
}
static void type_deduction() {
std::vector<int> v{1, 2, 3};
auto before = v;
for (auto& x : v) x *= 2;
report("loop with auto&", compare(v == before, v.empty()));
}
static void shared() {
auto p = std::make_shared<int>(9);
auto before = p.get();
auto copy_obj = p;
report("shared_ptr copy", compare(p.get() == before, p == nullptr));
std::printf("use count: %ld\n", static_cast<long>(p.use_count()));
auto moved = std::move(p);
report("shared_ptr moved", compare(p.get() == before, p == nullptr));
}
static void weak_reference() {
auto p = std::make_shared<int>(21);
std::weak_ptr<int> w = p;
bool reachable_before = !w.expired();
p.reset();
bool reachable_after = !w.expired();
std::printf("weak reference access: before %s, after %s\n",
reachable_before ? "yes" : "no",
reachable_after ? "yes" : "no");
}
static void move_only() {
auto p = std::make_unique<int>(5);
int* before = p.get();
auto&& r = std::move(p);
(void)r;
report("p after std::move alone", compare(p.get() == before, p == nullptr));
}
int main() {
smart(); moving(); rule_of_five(); non_copyable(); container();
iterator(); algorithm(); erase_remove(); lambda(); range_view(); type_deduction();
summary();
shared();
weak_reference();
move_only();
}
When these three files are compiled and run together, this is printed:
unique_ptr moved empty std::string moved unspecified std::move on class with dtor unchanged own move constructor empty operator[] read on a map changed erased iterator unspecified std::sort changed tail after std::remove unspecified mutable capture by value unchanged view over an lvalue unchanged loop with auto& changed kinds: unchanged 3, changed 3, empty 2, unspecified 3 state specified 8, not specified 3 shared_ptr copy unchanged use count: 2 shared_ptr moved empty weak reference access: before yes, after no p after std::move alone unchanged
OM1. The first thirteen lines define what this course measures: eleven distinct operations, each answering one question — what is left in the source once the operation finishes? The answer is one of four kinds: the source can stay unchanged, come out changed in a way the standard specifies, become empty by a guarantee the standard or the class itself gives, or fall into an unspecified state the standard says nothing about. The distribution across the eleven operations — 3 unchanged, 3 changed, 2 empty, 3 unspecified — stays the same from this lesson to the course’s closing; each lesson deepens its own slice of these eleven with its own topic.
The Moved unique_ptr: What the Standard’s Word Actually Guarantees
The first line of the output above reads unique_ptr moved empty. This is a promise the standard makes explicitly for std::unique_ptr: when a unique_ptr is moved into another unique_ptr through the move constructor or move assignment, the source object holds nullptr. This shows exactly where a smart pointer differs from a raw pointer: a raw pointer gives no guarantee at all about what it holds after being moved, but unique_ptr does.
This guarantee holds only for the pointer itself, not for the content of the object it points to. Trying to access *p on a moved unique_ptr — since p already holds nullptr — dereferences a null pointer, and that is undefined behavior. This lesson neither writes nor runs such a call; the defined counterpart is asking about emptiness first, with if (p). The negation of the w.expired() check in the weak_reference function is another instance of the same discipline: instead of reaching directly into a resource that is not held, it asks first whether it is reachable.
Unique, Shared, and Weak Ownership
std::make_unique and std::make_shared, as seen in the code above, are the standard way to construct a smart pointer — preferred over writing a raw new call directly. The ownership the three carry differs. unique_ptr carries unique ownership: only one unique_ptr can hold an object at a time, so it cannot be copied, only moved. shared_ptr carries shared ownership: several shared_ptr instances can point to the same object, and a reference count tracks how many do.
The shared function shows this difference through two operations. First copy_obj = p; opens a copy; the shared_ptr copy unchanged line in the output says the source (p) still points to the same object after the copy — p.get() did not change, p is still not nullptr. The use count: 2 printed right after confirms that the reference count rose along with the copy. Then auto moved = std::move(p); runs; this time the shared_ptr moved empty line shows that moving gives a result completely different from copying — the source is now nullptr.
OM2. Two different operations on the same class (shared_ptr) fall into two different kinds: copying leaves the source unchanged, moving leaves it empty. This confirms once more that what this course measures is not the class itself but the operation run on it.
Weak Reference and Reference Cycle
The third form of ownership is weak ownership: std::weak_ptr points to an object but does not raise the reference count, and so does not extend the object’s lifetime. The weak_reference function shows this: w holds a weak reference to the object p points to; before the p.reset() call, w.expired() is false (the object is still alive); after the call, it is true (the object is now gone). The weak reference access: before yes, after no line in the output shows exactly this.
The reason a weak_ptr exists is to stand ahead of a reference cycle that would form with shared_ptr. If two objects hold each other with shared_ptr, neither reference count ever drops to zero, and neither object is ever destroyed — this is a memory leak. This lesson does not build and run such a cycle; instead it runs its defined counterpart: making one end of the cycle a shared_ptr and the other a weak_ptr, closing off the leak from the start. The w.lock() call — not used directly in this lesson — is the standard way to safely obtain a temporary shared_ptr from a weak_ptr: it returns a populated shared_ptr if the object is still alive, and an empty one if it is gone.
The question M11/K02 measured — “who owns the memory” — gets a different answer depending on the language. In Rust (M10/K02), once a variable is moved, the compiler treats that variable as no longer usable and refuses to compile code that tries to use it — a moved variable can never be read again, and no destruction code runs for it at scope end either. In C++ the situation differs: the moved object keeps living, its destructor still runs; the compiler does not forbid using a moved object, it only leaves which value it now holds to be settled by this course’s four kinds. Ownership is declared plainly through the smart pointer’s type (unique, shared, weak), but what this course itself measures is not who holds ownership — it is what is left in the source itself once an operation finishes.
The Bounding Measurement
The move_only function shows this lesson’s finest point: std::move alone moves nothing. The line auto&& r = std::move(p); only converts p into an rvalue (the name for this conversion comes later); it calls no move constructor or move assignment, because r is a reference and does not construct a new unique_ptr. The p after std::move alone unchanged line in the output confirms this — p.get() is still the same as before, p is still not nullptr.
What empties the source is not the std::move call itself; what empties it is a move constructor or move assignment actually being called on that converted value. The line auto q = std::move(p); in the smart function empties p because that line constructs a new unique_ptr (q), and this construction calls unique_ptr’s move constructor. In move_only there is no such construction; that is why the result falls into a different kind. This distinction returns in a later lesson, where it is shown that a write like std::move is nothing but a type conversion.
Summary
- What is left in the source after an operation is named with four kinds: unchanged, changed, empty, unspecified; this course’s eleven lessons measure different slices of these four kinds.
- A moved
unique_ptr’s source becomesnullptr— this is a promise the standard gives; dereferencing a moved pointer is undefined behavior, and its defined counterpart is theif (p)check. - A
shared_ptrcopy leaves the source unchanged and raises the reference count; ashared_ptrmove leaves the source empty — same class, different operation, different result. weak_ptrpoints to an object without raising the reference count, and provides a defined counterpart to a reference cycle leak.std::movealone moves nothing; only a move constructor or move assignment that is actually called empties the source — this lesson’s bounding measurement is astd::movecall that leaves the source unchanged.
Next Step
This lesson looked at the outcome of moving: when a unique_ptr is moved, the source empties, because the standard gives this promise for the pointer itself. But when the thing being moved is not a pointer but a type like std::string that carries its own data, the standard does not speak with the same certainty. The next lesson opens up rvalue, lvalue, and the move constructor to measure this less certain outcome.
To keep your progress and take notes, Log in
My notes
Log in to take notes.