C++ Smart Pointers and Move Semantics, Measured
C++ lecture 4 on clang 21: unique_ptr, shared_ptr, weak_ptr, a cycle leak seen in destructor prints, std::move, and what noexcept does to vector growth.
Series: C++ lecture 4/6 (intermediate). For readers who know classes, constructors and RAII (lecture 3) and now see std::unique_ptr, std::move and && in every code review. After this lecture you can pick the right smart pointer, find a shared_ptr cycle with destructor prints and the macOS leaks tool, say what is left in a moved-from object, and explain why one missing noexcept made std::vector three times slower.
Smart pointers and move semantics are the two features that separate "C with classes" from modern C++, and they are where code from Java, C# or Swift developers goes wrong most often. The mistakes are quiet: a cycle that never frees, a "move" that copies, a raw pointer that outlives its owner. Everything below was compiled and run with Apple clang 21.0.0 and libc++ on an Apple Silicon Mac (M1 Pro), macOS 26, flags clang++ -std=c++23 -Wall -Wextra -pedantic, -O2 for timings. GCC, MSVC, Linux and Valgrind were not run here.
Three smart pointers in one program
A smart pointer is a small object that owns a heap object and deletes it in its destructor (the RAII idea from lecture 3). The standard library gives you three:
std::unique_ptr<T>: exactly one owner. Cannot be copied, only moved.std::shared_ptr<T>: shared ownership with a reference count; the last owner deletes the object.std::weak_ptr<T>: watches ashared_ptrobject without owning it;lock()gives you ashared_ptrif the object still exists.
#include <cstdio>
#include <memory>
#include <string>
struct Res {
std::string name;
explicit Res(std::string n) : name(std::move(n)) { std::printf(" acquire %s\n", name.c_str()); }
~Res() { std::printf(" release %s\n", name.c_str()); }
};
std::unique_ptr<Res> make(const char *n) { return std::make_unique<Res>(n); }
int main() {
std::printf("sizeof raw=%zu unique_ptr=%zu shared_ptr=%zu weak_ptr=%zu\n",
sizeof(Res *), sizeof(std::unique_ptr<Res>),
sizeof(std::shared_ptr<Res>), sizeof(std::weak_ptr<Res>));
{
std::printf("block 1\n");
auto a = make("A");
auto b = std::move(a); // ownership moves; a is now null
std::printf(" a=%s b=%s\n", a ? "set" : "null", b ? b->name.c_str() : "null");
}
std::printf("block 2\n");
std::shared_ptr<Res> s1 = std::make_shared<Res>("S");
{
auto s2 = s1;
std::printf(" use_count=%ld\n", s1.use_count());
std::weak_ptr<Res> w = s1;
std::printf(" weak use_count=%ld expired=%d\n", w.use_count(), w.expired());
}
std::printf(" use_count=%ld\n", s1.use_count());
std::weak_ptr<Res> w = s1;
s1.reset();
std::printf(" after reset: expired=%d lock()=%s\n", w.expired(), w.lock() ? "obj" : "nullptr");
std::printf("end of main\n");
}
sizeof raw=8 unique_ptr=8 shared_ptr=16 weak_ptr=16
block 1
acquire A
a=null b=A
release A
block 2
acquire S
use_count=2
weak use_count=2 expired=0
use_count=1
release S
after reset: expired=1 lock()=nullptr
end of main
What to read from this output:
unique_ptris 8 bytes, the same as a raw pointer. With the default deleter it costs nothing in size.shared_ptrandweak_ptrare 16: one pointer to the object, one to the control block that holds the counts.release Aprinted at the closing brace of block 1, not at the end ofmain. You never wrotedelete.- A
weak_ptrdoes not raiseuse_count(still 2 withs1,s2andw). After the last owner resets,lock()returnsnullptrinstead of a dangling pointer.
Copying a unique_ptr is a compile error, and it is a good one:
copyerr.cpp:4:10: error: call to implicitly-deleted copy constructor of 'unique_ptr<int>'
4 | auto b = a;
| ^ ~
.../c++/v1/__memory/unique_ptr.h:209:55: note: copy constructor is implicitly deleted because 'unique_ptr<int>' has a user-declared move constructor
The fix is to say what you mean: auto b = std::move(a); transfers ownership, or auto& b = a; borrows it.
make_shared does one allocation, not two. We replaced the global operator new with a counting version:
make_unique: 1 allocation(s)
shared_ptr<T>(new T): 2 allocation(s)
make_shared: 1 allocation(s)
shared_ptr<T>(new T) allocates the object, then a separate control block. make_shared puts both in one block. Prefer make_unique and make_shared; they also never leave a bare new in your code. (make_unique is C++14: with -std=c++11 clang said no member named 'make_unique' in namespace 'std'.)
The shared_ptr cycle: a leak you can count
Reference counting has one blind spot. If A owns B and B owns A, neither count reaches zero:
#include <cstdio>
#include <memory>
#include <string>
static int alive = 0;
struct Person {
std::string name;
std::shared_ptr<Person> partner; // strong link: the bug
// std::weak_ptr<Person> partner; // weak link: the fix
explicit Person(std::string n) : name(std::move(n)) { ++alive; std::printf("ctor %s\n", name.c_str()); }
~Person() { --alive; std::printf("dtor %s\n", name.c_str()); }
};
int main() {
{
auto a = std::make_shared<Person>("alice");
auto b = std::make_shared<Person>("bob");
a->partner = b;
b->partner = a;
std::printf("use_count a=%ld b=%ld\n", a.use_count(), b.use_count());
}
std::printf("after scope: alive=%d\n", alive);
}
ctor alice
ctor bob
use_count a=2 b=2
after scope: alive=2
Two constructors, zero destructors. When the scope ends, a and b drop the counts from 2 to 1, and the objects keep each other alive forever. Nothing crashes and ASan is silent, which is why this leak survives code review. Counting destructor prints, as above, is the cheapest test you can write.
The leaks tool that ships with macOS names the pattern directly (trimmed):
$ leaks --atExit -- ./cycle
Process 42649: 2 leaks for 160 total leaked bytes.
2 (160 bytes) ROOT CYCLE: <std::__shared_ptr_emplace<Person> 0x726c24050> [80]
1 (80 bytes) ROOT CYCLE: <std::__shared_ptr_emplace<Person> 0x726c240a0> [80]
CYCLE BACK TO <std::__shared_ptr_emplace<Person> 0x726c24050> [80]
80 bytes each is the control block and the Person in one allocation, the make_shared layout from above. Swap the two comment lines (make partner a weak_ptr) and the output becomes:
ctor alice
ctor bob
use_count a=1 b=1
dtor bob
dtor alice
after scope: alive=0
Process 42675: 0 leaks for 0 total leaked bytes.
The rule: ownership must form a tree. Parent to child is shared_ptr (or unique_ptr); child to parent, observer to subject, cache to entry are weak_ptr or plain references. If you know Swift, this is the same problem as a retain cycle and weak var; our Swift ARC article shows the same leak in another language.
std::move does not move anything
std::move(x) is only a cast: it turns x into an rvalue (T&&) so that overload resolution picks the move constructor or move assignment if the type has one. The moving is done by that constructor, which usually steals the internal pointer and leaves the source empty.
std::string s = "a string long enough to live on the heap";
std::string t = std::move(s);
std::printf("s=\"%s\" size=%zu | t size=%zu\n", s.c_str(), s.size(), t.size());
std::vector<int> v(5, 1);
std::vector<int> w = std::move(v);
v.push_back(3); // valid: reuse after move is allowed
const std::string c = "const string that cannot be moved from at all";
std::string d = std::move(c); // silently copies
s="" size=0 | t size=40
small="" size=0
v.size=0 w.size=5
after push_back v.size=1
c still="const..." size=45
- A moved-from object is in a valid but unspecified state. With libc++ the long string, a 5-character short string and the vector were all empty, but the standard only promises "valid". Assign it or destroy it; do not read it.
std::moveon aconstobject compiles without any warning under-Wall -Wextra -pedanticand copies.const std::string&&cannot bind to the move constructor, so the copy constructor wins.clang++ --analyzedid catch use-after-move:Method called on moved-from object 's' of type 'std::basic_string' [cplusplus.Move], three reports for our three objects.
The opposite mistake is adding std::move where the compiler already does better:
std::vector<int> build() {
std::vector<int> v(1000, 7);
return std::move(v); // looks faster, is not
}
movedfrom.cpp:8:12: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
movedfrom.cpp:8:12: note: remove std::move call here
return v; lets the compiler build the result directly in the caller's variable (copy elision). Write return v;.
Copy vs move, measured
How big is the difference? We built a std::vector<std::string> of 1,000,000 strings of 32 random letters (std::mt19937 seed 42), then copied it and moved it. -O2, 7 runs, median.
auto t0 = clk::now();
std::vector<std::string> copy = src; // copy: 1,000,001 heap allocations
auto t1 = clk::now();
std::vector<std::string> moved = std::move(copy); // move: 3 pointers change hands
auto t2 = clk::now();
copy: median 16.97 ms
move: median 0 ns
"0 ns" is not a real number: steady_clock on this Mac ticks in steps of about 42 ns (two back-to-back now() calls printed 42 and 0), so the move was below the clock's resolution. To get a real figure we moved the same vector back and forth 10,000,000 times with a compiler barrier and divided:
1.56 ns per move assignment, a.size=1000000
Copying took about 17 ms; moving took about 1.6 ns, roughly ten million times less, and the move cost does not depend on the size. The copy does a heap allocation per string because 32 characters do not fit in the string's inline buffer (lecture 6 measures where that limit is).
noexcept and vector growth: copies you did not ask for
When a std::vector runs out of capacity, it allocates a bigger block and relocates the old elements. It moves them only if the move constructor is noexcept; otherwise it copies. The reason is the strong exception guarantee: if a move throws halfway, the old elements are already gutted and cannot be restored, while a failed copy leaves the old block intact.
We counted (timing column trimmed from the first four lines) with a type whose only difference is the noexcept on its move constructor:
static long copies = 0, moves = 0;
template <bool NoExcept>
struct Item {
std::string s;
explicit Item(int i) : s(40, char('a' + i % 26)) {}
Item(const Item &o) : s(o.s) { ++copies; }
Item(Item &&o) noexcept(NoExcept) : s(std::move(o.s)) { ++moves; }
Item &operator=(const Item &) = default;
Item &operator=(Item &&) = default;
};
// for (int i = 0; i < n; ++i) v.push_back(Item<NoExcept>(i));
move without noexcept n=1000 copies=1023 moves=1000 (nothrow_move=0)
move with noexcept n=1000 copies=0 moves=2023 (nothrow_move=1)
move without noexcept n=1000000 copies=1048575 moves=1000000 (nothrow_move=0)
move with noexcept n=1000000 copies=0 moves=2048575 (nothrow_move=1)
median of 7: without noexcept 43.32 ms, with noexcept 14.57 ms
The 1,000 moves in the first line come from push_back(Item(i)) itself, which moves the temporary in either case. The 1,023 copies are the relocations: libc++ doubles the capacity (1, 2, 4, …, 1024), so it relocated 1 + 2 + … + 512 = 1,023 elements. Without noexcept, every one of them is a full string copy with its own allocation. With a million elements that is 1,048,575 copies and 3× the time.
How to get noexcept right without thinking about it: follow the rule of zero from lecture 3. A struct Plain { std::string s; }; with no user-written special members gets a compiler-generated move constructor, and std::is_nothrow_move_constructible_v<Plain> printed 1. Problems start when you write one special member by hand. Exercise 2 shows the sneakiest version.
Common mistakes → what actually happened → fix
We put three ownership bugs in one file and ran it plain and with -fsanitize=address,undefined -g.
struct Widget : std::enable_shared_from_this<Widget> {
int id = 7;
std::shared_ptr<Widget> self_wrong() { return std::shared_ptr<Widget>(this); }
std::shared_ptr<Widget> self_right() { return shared_from_this(); }
};
// in main(), selected by argv[1]:
if (mode == 1) { // two owners of one raw pointer
int *raw = new int(42);
std::unique_ptr<int> a(raw);
std::unique_ptr<int> b(raw);
} else if (mode == 2) { // raw pointer outlives its owner
auto up = std::make_unique<Widget>();
Widget *view = up.get();
up.reset();
std::printf("id=%d\n", view->id);
} else if (mode == 3) { // shared_ptr(this)
auto sp = std::make_shared<Widget>();
auto again = sp->self_wrong();
}
| Mistake | Plain build | With ASan | Fix |
|---|---|---|---|
two unique_ptr from one new |
Trace/BPT trap: 5, exit 133 |
attempting double-free, line 19 |
create with make_unique, never wrap a raw pointer twice |
using get() after reset() |
printed id=0, exit 0 |
heap-use-after-free, line 23 |
keep the owner alive, or use shared_ptr + weak_ptr |
shared_ptr<T>(this) inside a method |
Trace/BPT trap: 5, exit 133 |
bad-free (attempting free on address which was not malloc()-ed) |
inherit enable_shared_from_this, call shared_from_this() |
shared_ptr both ways |
no error, destructors never run | silent | weak_ptr for the back link; leaks --atExit |
return std::move(local); |
works, but blocks elision | — | return local; (-Wpessimizing-move) |
| user-declared destructor, no move | vector copies on growth | — | rule of zero, or = default all five |
Mode 2 is the dangerous one: the plain build printed a plausible id=0 and exited normally. Mode 3 is a real-world pattern (a callback registering "itself"): two control blocks own the same object. With shared_from_this() both pointers printed use_count=2, i.e. one shared control block.
Which owner should I use?
| Situation | Use | Why |
|---|---|---|
| one clear owner (a member, a factory result) | std::unique_ptr<T> |
8 bytes, no count, moves cheaply |
| several owners with unclear end of life (shared cache entries, async callbacks) | std::shared_ptr<T> |
count in a control block; 16 bytes, atomic count updates |
| back link, observer, cache that must not keep things alive | std::weak_ptr<T> |
does not own; lock() to use |
| a function only uses the object | T& or T* parameter |
no ownership in the signature |
| an array of objects | std::vector<T> |
already owns its elements |
Start with unique_ptr; it converts to shared_ptr later (std::shared_ptr<T> s = std::move(u);) but not back. And do not pass smart pointers by value to functions that only read the object: void draw(const Shape&) is clearer than void draw(std::shared_ptr<Shape>), and does not touch an atomic counter on every call.
Honest limits / version notes
- Run only with Apple clang 21.0.0 (libc++,
_LIBCPP_VERSION220106) on arm64 macOS 26. GCC/libstdc++, MSVC, Linux and Valgrind were not run here; libstdc++'s moved-from strings and growth factor may differ. - Versions:
unique_ptr,shared_ptr,weak_ptr, move semantics andnoexceptare C++11;make_uniqueis C++14 (checked: rejected under-std=c++11). Everything here also compiled with-std=c++17and-std=c++20. - The growth factor of 2 and the allocation sizes (80 bytes per
make_shared<Person>) are libc++ details, not standard. - Timings are single-machine medians; the copy took 16.5 to 17.0 ms across runs, the noexcept test 43.3 vs 14.6 ms in two separate runs.
- AddressSanitizer's leak checker does not run on Apple Silicon macOS (see C lecture 3); that is why we used
leaks.
Exercises
1. Build a tree where each Node has children and a link to its parent. Choose the pointer types so that dropping the root frees everything, and prove it with destructor prints and leaks --atExit.
2. This type makes std::vector slow. Nothing in it mentions moves. Why?
struct Logged {
std::string text;
~Logged() {} // added "just to set a breakpoint"
};
Answers
1. Parent to child owns, child to parent watches:
struct Node {
std::string name;
std::weak_ptr<Node> parent; // child -> parent: weak
std::vector<std::shared_ptr<Node>> children; // parent -> child: owning
explicit Node(std::string n) : name(std::move(n)) {}
~Node() { std::printf("dtor %s\n", name.c_str()); }
};
With a root, a child a and a grandchild a1 kept alive by an outside leaf pointer, it printed a1's parent is a, then dtor root, dtor a when the scope ended, tree gone? parent expired=1, and finally dtor a1. leaks reported 0 leaks for 0 total leaked bytes. Note that lock() returns a temporary shared_ptr, so use it inside an if (auto p = ...).
2. A user-declared destructor suppresses the implicit move constructor. The type still copies, so std::is_nothrow_move_constructible_v<Logged> printed 0 (it falls back to the string copy, which may throw). We counted allocations while pushing the second 1,000 elements into a vector that grows from 1,024 to 2,048 slots: Logged=3025, the same type without the destructor Clean=1001. The extra 2,024 are 1,024 relocation copies plus 1,000 copies of the pushed temporaries. Delete the empty destructor (rule of zero), or add Logged(Logged&&) noexcept = default; and the other defaulted members.
Next
Lecture 5 is about templates: how to read a 114-line template error, how C++20 concepts cut it to 22 lines, STL algorithms with lambdas, lazy ranges views, and std::sort vs std::stable_sort vs qsort measured.
FAQ
Why does std::vector copy my objects instead of moving them when it grows?
Because your move constructor is not noexcept. To keep its strong exception guarantee, vector only moves elements during reallocation if the move cannot throw; otherwise it copies. In our test, pushing 1,000,000 elements made 1,048,575 copies without noexcept and 0 copies with it, and took 43.3 ms vs 14.6 ms. A user-declared destructor has the same effect, because it suppresses the implicit move constructor.
Can shared_ptr leak memory?
Yes, when two objects hold shared_ptr to each other. In our test two Person objects linked both ways printed no destructor at all, alive stayed at 2 after the scope ended, and the macOS leaks tool reported "2 leaks for 160 total leaked bytes" as a ROOT CYCLE. Making one direction a weak_ptr fixed it: both destructors ran and leaks reported 0.
What is left in an object after std::move?
A valid but unspecified state. With libc++ both a moved-from std::string (long or short) and a moved-from std::vector were empty, and calling push_back on the vector afterwards was fine. Do not rely on the exact contents; only assign a new value or destroy it. std::move on a const object does not move at all: it silently copies, with no warning.