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 a shared_ptr object without owning it; lock() gives you a shared_ptr if 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:

  1. unique_ptr is 8 bytes, the same as a raw pointer. With the default deleter it costs nothing in size. shared_ptr and weak_ptr are 16: one pointer to the object, one to the control block that holds the counts.
  2. release A printed at the closing brace of block 1, not at the end of main. You never wrote delete.
  3. A weak_ptr does not raise use_count (still 2 with s1, s2 and w). After the last owner resets, lock() returns nullptr instead 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.

Decision diagram. Start: does this code need to own the object? If no, use a reference or raw pointer parameter. If yes, ask whether there is exactly one owner. If yes, use unique_ptr. If no, use shared_ptr. From shared_ptr, a back link or observer that must not keep the object alive uses weak_ptr, and a two-way shared_ptr link leads to a cycle leak.
Choosing an owner for a heap object. Ownership should form a tree; back links use weak_ptr.

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::move on a const object compiles without any warning under -Wall -Wextra -pedantic and copies. const std::string&& cannot bind to the move constructor, so the copy constructor wins.
  • clang++ --analyze did 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.

Bar chart. With a move constructor that is not noexcept, vector growth made 1,048,575 copies and took 43.32 milliseconds. With a noexcept move constructor it made 0 copies and took 14.57 milliseconds.
push_back of 1,000,000 elements holding a 40-char string. Median of 7 runs, -O2, Apple clang 21 / libc++, Apple Silicon Mac (M1 Pro).

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_VERSION 220106) 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 and noexcept are C++11; make_unique is C++14 (checked: rejected under -std=c++11). Everything here also compiled with -std=c++17 and -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.

  • #cpp
  • #intermediate
  • #smart-pointers
  • #move-semantics
  • #noexcept
  • #addresssanitizer

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑