Smart Pointers in C++: unique_ptr, shared_ptr and weak_ptr Explained with Tested Code

How C++ smart pointers own memory, measured: what each one costs in bytes and allocations, why shared_ptr cycles leak, and which one to use where.

Diagram-style illustration of one box tied to a single owner tag beside a box tied to three owner tags and one dashed observer tag, representing unique_ptr, shared_ptr and weak_ptr.

A std::unique_ptr<int> is 8 bytes on x86-64 Linux, the same as the int* it replaces. Give it a function-pointer deleter and it becomes 16. Build a std::shared_ptr with std::make_shared and you get one heap allocation instead of two, plus a catch: a single leftover std::weak_ptr keeps the whole block reserved after the object is gone. Smart pointers make ownership part of the type, and nearly everything they cost is visible in the declaration if you know where to look.

This guide covers std::unique_ptr, std::shared_ptr and std::weak_ptr, custom deleters, the make_shared trade-off, reference cycles, threads, and how to pass smart pointers to functions. Every program was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 and Clang 18.1.3 using -std=c++17 -Wall -Wextra -pedantic (C++20 for one example) with no warnings; the leak, double-free and data-race reports come from AddressSanitizer and ThreadSanitizer runs, and every output block is captured verbatim. The code is in a GitHub repository whose build repeats these checks on each commit.

The Short Answer

What is a smart pointer in C++? A class template from <memory> that owns a heap object and destroys it in its own destructor, so the object’s lifetime follows a scope or an owner rather than a matching delete.

Which one should I use? std::unique_ptr unless ownership is shared. std::shared_ptr when several parts of a program must keep the same object alive. std::weak_ptr when you need to refer to a shared_ptr-owned object without keeping it alive.

Do smart pointers replace raw pointers? They replace owning raw pointers. A function that only uses an object should still take T& or T*; passing a smart pointer says something about ownership, and a function that does not touch ownership has nothing to say.

What Is a Smart Pointer?

A smart pointer is an object that holds a pointer and is responsible for deleting what it points to. It applies RAII (Resource Acquisition Is Initialization): the resource is acquired in a constructor and released in a destructor, so it is freed on every path out of a scope, including early returns and exceptions. C++ provides three in <memory>: std::unique_ptr, std::shared_ptr and std::weak_ptr.

The three differ in who owns the object. A std::unique_ptr is the single owner and cannot be copied, only moved. A std::shared_ptr is one of possibly many owners, tracked by a reference count in a separately allocated control block. A std::weak_ptr refers to an object owned by shared_ptrs without being an owner itself. The figure shows what each one looks like in memory.

What a smart pointer points at Sizes and allocations captured on x86-64 Linux, libstdc++ 13 (GCC 13.3 and Clang 18) std::unique_ptr<Payload> 8 bytes · no control block p ptr 8 B Payload (32 B) One owner. When p is destroyed or reset, the Payload is deleted. std::make_shared<Payload>() 1 allocation · 48 bytes a ptr ctrl b ptr ctrl w ptr ctrl control block vptr + counts (16 B) Payload (32 B) Payload destroyed when a and b are gone. Block freed only after w is gone too. a and b are shared_ptrs, w is a weak_ptr; each handle is 16 B. b.ptr and w.ptr hold the same address as a.ptr (one arrow drawn). std::shared_ptr<Payload>(new Payload) 2 allocations · 32 B + 24 B c ptr ctrl Payload (32 B) control block (24 B) Payload memory is returned at use_count 0, even if a weak_ptr remains.
The three layouts differ in when memory comes back, not only in how much is used. A unique_ptr is one pointer with no bookkeeping. make_shared puts the counts and the object in one 48-byte allocation, so a surviving weak_ptr keeps all 48 bytes reserved after the object is destroyed. Constructing from new costs a second allocation but releases the object’s 32 bytes as soon as the last owner goes. Byte counts are libstdc++ figures from sizes.cpp and allocations.cpp; other standard libraries lay out the control block differently.

Why Raw new and delete Leak

The usual argument for smart pointers is that people forget delete. The more accurate one is that delete has to be written on every path out of a function, and paths get added later. This program has the same function twice; only the early return differs.

// raw_vs_unique.cpp - the same function written with new/delete and with
// std::unique_ptr. Only the early-return path differs.
#include <cstdio>
#include <memory>
#include <string>
#include <utility>

struct Buffer {
    explicit Buffer(std::string n) : name(std::move(n)) { std::printf("  acquire %s\n", name.c_str()); }
    ~Buffer() { std::printf("  release %s\n", name.c_str()); }
    std::string name;
};

bool parse_raw(bool bad_input)
{
    Buffer* buf = new Buffer("raw");
    if (bad_input)
        return false;            // returns without delete: the Buffer leaks
    delete buf;
    return true;
}

bool parse_unique(bool bad_input)
{
    auto buf = std::make_unique<Buffer>("unique");
    if (bad_input)
        return false;            // ~unique_ptr runs here
    return true;                 // and here
}

int main()
{
    std::puts("parse_raw(bad input):");
    parse_raw(true);
    std::puts("parse_unique(bad input):");
    parse_unique(true);
}

Output:

parse_raw(bad input):
  acquire raw
parse_unique(bad input):
  acquire unique
  release unique

The raw version never prints release raw. Nothing in the normal output says that anything went wrong, which is why this kind of leak survives testing. Built with -fsanitize=address, the same program fails and points at the allocation (excerpt; library frames trimmed):

==2041==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 32 byte(s) in 1 object(s) allocated from:
    #0 0x7efcd72fe548 in operator new(unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:95
    #1 0x55fab3d725dd in parse_raw(bool) src/raw_vs_unique.cpp:16
    #2 0x55fab3d728f2 in main src/raw_vs_unique.cpp:34
SUMMARY: AddressSanitizer: 32 byte(s) leaked in 1 allocation(s).
exit status 1

std::make_unique moves the cleanup into the unique_ptr destructor, which the compiler runs on each return and when an exception propagates out of the function (provided something up the stack catches it; an uncaught exception may end the program without unwinding). Adding a fourth return path later needs no extra cleanup code.

std::unique_ptr: Exclusive Ownership

std::unique_ptr<T> owns one object and deletes it when the unique_ptr is destroyed or reset. It can be moved, which transfers ownership, but not copied. That makes it the right return type for factories and the right parameter type for functions that take ownership.

// unique_basics.cpp - creating, moving, releasing and resetting std::unique_ptr.
#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Widget {
    explicit Widget(int id) : id(id) { std::cout << "  Widget " << id << " created\n"; }
    ~Widget() { std::cout << "  Widget " << id << " destroyed\n"; }
    int id;
};

// A factory states in its signature that the caller becomes the owner.
std::unique_ptr<Widget> make_widget(int id)
{
    return std::make_unique<Widget>(id);
}

// A sink takes ownership by value; the Widget dies when this function returns.
void consume(std::unique_ptr<Widget> w)
{
    std::cout << "  consume() got Widget " << w->id << '\n';
}

int main()
{
    std::cout << "1. factory\n";
    auto a = make_widget(1);

    std::cout << "2. move into b\n";
    std::unique_ptr<Widget> b = std::move(a);
    std::cout << "  a is " << (a ? "non-null" : "null")
              << ", b owns Widget " << b->id << '\n';

    std::cout << "3. pass to a sink\n";
    consume(std::move(b));
    std::cout << "  back in main, b is " << (b ? "non-null" : "null") << '\n';

    std::cout << "4. reset replaces the owned object\n";
    auto c = std::make_unique<Widget>(2);
    c.reset(new Widget(3));      // Widget 3 is built, then Widget 2 is destroyed

    std::cout << "5. release gives up ownership without destroying\n";
    Widget* raw = c.release();
    std::cout << "  c is " << (c ? "non-null" : "null") << ", raw->id = " << raw->id << '\n';
    delete raw;                  // after release(), deleting is our job again

    std::cout << "6. array form\n";
    auto values = std::make_unique<int[]>(4);   // value-initialised: all zero
    values[2] = 7;
    for (int i = 0; i < 4; ++i)
        std::cout << "  values[" << i << "] = " << values[i] << '\n';

    std::cout << "7. end of main\n";
}

Output:

1. factory
  Widget 1 created
2. move into b
  a is null, b owns Widget 1
3. pass to a sink
  consume() got Widget 1
  Widget 1 destroyed
  back in main, b is null
4. reset replaces the owned object
  Widget 2 created
  Widget 3 created
  Widget 2 destroyed
5. release gives up ownership without destroying
  c is null, raw->id = 3
  Widget 3 destroyed
6. array form
  values[0] = 0
  values[1] = 0
  values[2] = 7
  values[3] = 0
7. end of main

Each step teaches something:

  • Step 2: after std::move, a is null. A moved-from unique_ptr is guaranteed empty, so testing it is well defined.
  • Step 3: Widget 1 destroyed prints inside consume(), before control returns to main. Passing a unique_ptr by value moved the object’s lifetime into the callee.
  • Step 4: reset(new Widget(3)) constructs the new object first and destroys the old one second.
  • Step 5: release() gives up ownership without deleting. It is for handing the pointer to code that will take ownership in some other way; calling it and discarding the result is a leak.
  • Step 6: std::make_unique<int[]>(4) value-initializes, so the elements start at zero. The array form calls delete[], which is why std::unique_ptr<int[]> and std::unique_ptr<int> are different types.

Copying a unique_ptr Is a Compile Error

Copying would create two owners, so the copy constructor is deleted:

// copy_unique.cpp - must NOT compile: std::unique_ptr has no copy constructor.
#include <memory>

int main()
{
    auto a = std::make_unique<int>(42);
    auto b = a;
    return *b;
}

GCC 13.3 rejects it (excerpt):

tests/compile-fail/copy_unique.cpp:7:14: error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = int; _Dp = std::default_delete<int>]'
    7 |     auto b = a;
  522 |       unique_ptr(const unique_ptr&) = delete;

Clang 18 reports the same thing more briefly, as a call to deleted constructor. Either message means the fix is std::move(a), if transferring ownership is what you intended, or a shared_ptr, if two owners are.

What unique_ptr Costs

With the default deleter, a unique_ptr stores one pointer. The deleter type is part of the unique_ptr type, and a deleter with no state takes no space. A deleter that is a function pointer has to be stored, and that doubles the size.

// sizes.cpp - what each smart pointer costs in bytes on this platform.
#include <cstdio>
#include <memory>

struct FileCloser {                       // stateless deleter type
    void operator()(std::FILE* f) const noexcept { std::fclose(f); }
};

int main()
{
    auto lambda_closer = [](std::FILE* f) { std::fclose(f); };

    std::printf("%-46s %zu\n", "int*", sizeof(int*));
    std::printf("%-46s %zu\n", "std::unique_ptr<int>", sizeof(std::unique_ptr<int>));
    std::printf("%-46s %zu\n", "std::unique_ptr<FILE, FileCloser>",
                sizeof(std::unique_ptr<std::FILE, FileCloser>));
    std::printf("%-46s %zu\n", "std::unique_ptr<FILE, decltype(lambda)>",
                sizeof(std::unique_ptr<std::FILE, decltype(lambda_closer)>));
    std::printf("%-46s %zu\n", "std::unique_ptr<FILE, void (*)(FILE*)>",
                sizeof(std::unique_ptr<std::FILE, void (*)(std::FILE*)>));
    std::printf("%-46s %zu\n", "std::shared_ptr<int>", sizeof(std::shared_ptr<int>));
    std::printf("%-46s %zu\n", "std::weak_ptr<int>", sizeof(std::weak_ptr<int>));
}

Output (GCC 13.3 and Clang 18.1.3 with libstdc++, x86-64; identical for both):

int*                                           8
std::unique_ptr<int>                           8
std::unique_ptr<FILE, FileCloser>              8
std::unique_ptr<FILE, decltype(lambda)>        8
std::unique_ptr<FILE, void (*)(FILE*)>         16
std::shared_ptr<int>                           16
std::weak_ptr<int>                             16
Pointer typeSize on this platformWhat it stores
int*8 bytesThe address
std::unique_ptr<int>8 bytesThe address; std::default_delete is empty
std::unique_ptr with a stateless struct or lambda deleter8 bytesThe address; the deleter takes no space
std::unique_ptr with a function-pointer deleter16 bytesThe address and the function pointer
std::shared_ptr<int>, std::weak_ptr<int>16 bytesThe address and a pointer to the control block

The size is the same as a raw pointer, but the type is not trivially the same. Under the Itanium C++ ABI used by GCC and Clang on Linux, a type with a non-trivial destructor is passed to a function through a temporary in memory rather than in a register (§3.1.2.3, Non-Trivial Parameters). These two forwarding functions do the same job:

#include <memory>
#include <utility>

void take_raw(int* p);
void take_unique(std::unique_ptr<int> p);

void forward_raw(int* p)                     { take_raw(p); }
void forward_unique(std::unique_ptr<int> p)  { take_unique(std::move(p)); }

Clang 18 at -O2 compiles forward_raw to one instruction (assembler directives and comments removed here and below):

	jmp	take_raw(int*)@PLT

and forward_unique to this, including the cleanup path for an exception thrown by take_unique:

	push	rbx
	sub	rsp, 16
	mov	rax, qword ptr [rdi]
	mov	qword ptr [rsp + 8], rax
	mov	qword ptr [rdi], 0
	lea	rdi, [rsp + 8]
	call	take_unique(std::unique_ptr<int, std::default_delete<int> >)@PLT
	mov	rdi, qword ptr [rsp + 8]
	test	rdi, rdi
	je	.LBB1_3
	call	operator delete(void*)@PLT
.LBB1_3:
	add	rsp, 16
	pop	rbx
	ret
.LBB1_4:
	mov	rbx, rax
	mov	rdi, qword ptr [rsp + 8]
	test	rdi, rdi
	je	.LBB1_6
	call	operator delete(void*)@PLT
.LBB1_6:
	mov	rdi, rbx
	call	_Unwind_Resume@PLT

The extra work is a stack temporary, a store of null into the moved-from argument, and a null check before operator delete after the call returns. Whether that matters depends on how often the function is called, but it is another reason to pass T* or T& to functions that do not take ownership, which is the advice the C++ Core Guidelines give anyway (rule F.7). It is measured here for x86-64 Linux; other ABIs make their own choices.

Custom Deleters: Managing a FILE* or Any C Handle

unique_ptr is not limited to memory. Anything with an “acquire” function and a “release” function fits, and C library handles are the common case. A deleter is any callable that takes the pointer:

// file_handle.cpp - std::unique_ptr managing a C FILE* with a custom deleter.
#include <cstdio>
#include <memory>

struct FileCloser {
    void operator()(std::FILE* f) const noexcept
    {
        std::puts("  fclose() called");
        std::fclose(f);
    }
};

using File = std::unique_ptr<std::FILE, FileCloser>;

// Returns an empty File if fopen fails; the deleter is never called on null.
File open_file(const char* path, const char* mode)
{
    return File(std::fopen(path, mode));
}

int main()
{
    const char* path = "smart_pointers_demo.txt";

    if (File out = open_file(path, "w")) {
        std::fputs("written through a unique_ptr<FILE>\n", out.get());
    }   // closed here, before the file is reopened

    File in = open_file(path, "r");
    if (!in) {
        std::perror(path);
        return 1;
    }
    char line[64];
    if (std::fgets(line, sizeof line, in.get()))
        std::printf("  read back: %s", line);

    File missing = open_file("no/such/dir/file.txt", "r");
    std::printf("  missing file handle is %s\n", missing ? "open" : "null");

    in.reset();                  // close now rather than at end of scope
    std::remove(path);
    std::puts("  end of main");
}

Output:

  fclose() called
read back: written through a unique_ptr<FILE>
missing file handle is null
fclose() called
end of main

fclose() runs twice, once for each file that opened, and not at all for the fopen() that returned null: unique_ptr calls its deleter only when it holds a non-null pointer. The first handle is closed at the end of the if block, before the file is reopened for reading, because that is where out goes out of scope.

The deleter is a small struct rather than decltype(&std::fclose) for two reasons. It is 8 bytes instead of 16, as the table above shows. And since C++20, the standard makes it unspecified, and possibly ill-formed, to take the address of a standard library function unless that function is designated addressable ([namespace.std]/6); fclose is not. Wrapping the call in a struct or a lambda avoids the question entirely.

std::shared_ptr: Shared Ownership

std::shared_ptr<T> keeps a count of how many shared_ptrs own the object. Copying one increments the count; destroying or resetting one decrements it; the object is deleted when the count reaches zero.

// shared_basics.cpp - how std::shared_ptr's use_count moves with copies,
// moves, scopes and reset().
#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Texture {
    explicit Texture(std::string n) : name(std::move(n)) { std::cout << "  load " << name << '\n'; }
    ~Texture() { std::cout << "  free " << name << '\n'; }
    std::string name;
};

int main()
{
    auto first = std::make_shared<Texture>("grass.png");
    std::cout << "after make_shared:  use_count = " << first.use_count() << '\n';

    {
        std::shared_ptr<Texture> second = first;            // copy: +1
        std::cout << "after copy:         use_count = " << first.use_count() << '\n';

        std::shared_ptr<Texture> third = std::move(second); // move: no change
        std::cout << "after move:         use_count = " << first.use_count()
                  << " (second is " << (second ? "non-null" : "null") << ")\n";
    }
    std::cout << "after inner scope:  use_count = " << first.use_count() << '\n';

    first.reset();                                          // last owner: object freed
    std::cout << "after reset:        first is " << (first ? "non-null" : "null") << '\n';
}

Output:

  load grass.png
after make_shared: use_count = 1
after copy: use_count = 2
after move: use_count = 2 (second is null)
after inner scope: use_count = 1
free grass.png
after reset: first is null

A copy raises use_count() and a move does not, because the moved-from pointer gives up its share. use_count() is useful for experiments like this one and for debugging. In multithreaded code its value can change between the call and the moment you act on it, so it is not a reliable basis for decisions.

make_shared vs shared_ptr(new T)

The control block that holds the counts has to live somewhere. std::make_shared allocates it and the object together; std::shared_ptr<T>(new T) cannot, because the object already exists when the shared_ptr sees it. This program replaces the global operator new and operator delete to log every allocation, and records the order of events:

// allocations.cpp - counts heap allocations made by std::make_shared and by
// std::shared_ptr<T>(new T), and shows when each one's memory is returned.
#include <cstdio>
#include <cstdlib>
#include <memory>
#include <new>

// --- an event log that does not itself allocate --------------------------
struct Event { const char* what; std::size_t bytes; };
static Event g_log[64];
static int g_count = 0;
static bool g_tracking = false;

static void log_event(const char* what, std::size_t bytes = 0)
{
    if (g_tracking && g_count < 64)
        g_log[g_count++] = {what, bytes};
}

void* operator new(std::size_t n)
{
    log_event("operator new", n);
    if (void* p = std::malloc(n))
        return p;
    throw std::bad_alloc();
}
void operator delete(void* p) noexcept { log_event("operator delete"); std::free(p); }
void operator delete(void* p, std::size_t) noexcept { log_event("operator delete"); std::free(p); }

// --- the object being shared ---------------------------------------------
struct Payload {
    ~Payload() { log_event("~Payload()"); }
    long data[4] = {};            // 32 bytes
};

static void run(const char* title, std::shared_ptr<Payload> (*create)())
{
    g_count = 0;
    g_tracking = true;
    {
        std::shared_ptr<Payload> owner = create();
        log_event("-- created; take a weak_ptr");
        std::weak_ptr<Payload> watcher = owner;
        log_event("-- owner.reset()");
        owner.reset();
        log_event("-- weak_ptr leaves scope");
    }
    g_tracking = false;

    std::printf("%s\n", title);
    for (int i = 0; i < g_count; ++i) {
        if (g_log[i].bytes)
            std::printf("  %-28s %zu bytes\n", g_log[i].what, g_log[i].bytes);
        else
            std::printf("  %s\n", g_log[i].what);
    }
}

int main()
{
    run("std::make_shared<Payload>()",
        [] { return std::make_shared<Payload>(); });
    run("std::shared_ptr<Payload>(new Payload)",
        [] { return std::shared_ptr<Payload>(new Payload); });
}

Output (GCC 13.3 and Clang 18.1.3 produced identical logs at -O0 and -O2):

std::make_shared<Payload>()
  operator new                 48 bytes
  -- created; take a weak_ptr
  -- owner.reset()
  ~Payload()
  -- weak_ptr leaves scope
  operator delete
std::shared_ptr<Payload>(new Payload)
  operator new                 32 bytes
  operator new                 24 bytes
  -- created; take a weak_ptr
  -- owner.reset()
  ~Payload()
  operator delete
  -- weak_ptr leaves scope
  operator delete

Both halves of the trade-off are in that log:

Propertystd::make_shared<Payload>()std::shared_ptr<Payload>(new Payload)
Heap allocations1 (48 bytes)2 (32 + 24 bytes)
~Payload() runsWhen the last shared_ptr goesWhen the last shared_ptr goes
Payload’s memory returnedWhen the last weak_ptr also goesWhen the last shared_ptr goes
Control block returnedWith the Payload, in one deleteWhen the last weak_ptr goes

The destructor runs at the same point in both cases. What differs is when the memory comes back. With make_shared, the object and the counts share one allocation, and the counts are needed for as long as any weak_ptr exists, so the object’s 32 bytes stay reserved too. The cppreference notes on make_shared describe the same trade-off.

For most objects, make_shared is the better default: one allocation instead of two, and the counts sit next to the object. Construct from new (or keep the weak_ptrs short-lived) when the object is large and weak_ptrs can outlive the last owner by a long time, as in a cache. The byte sizes here are libstdc++’s; other standard libraries lay out the control block differently.

Two Control Blocks for One Object

The control block is created when a shared_ptr first takes ownership of a raw pointer. Do that twice with the same pointer and you get two control blocks, each with a count of one:

// two_owners.cpp - two independent shared_ptrs built from one raw pointer.
// Each has its own control block, so each deletes the object.
#include <iostream>
#include <memory>

struct Session {
    ~Session() { std::cout << "  ~Session\n"; }
};

int main()
{
    Session* raw = new Session;
    std::shared_ptr<Session> a(raw);
    std::shared_ptr<Session> b(raw);      // second control block for the same object
    std::cout << "  a.use_count() = " << a.use_count()
              << ", b.use_count() = " << b.use_count() << '\n';
}

Output (three runs, identical):

  a.use_count() = 1, b.use_count() = 1
~Session
~Session
free(): double free detected in tcache 2
exit status 134

Each use_count() reports 1, which looks healthy, and then both control blocks run the destructor on the same object. glibc detected the second free and aborted the program. AddressSanitizer names the problem directly (first line of the report):

==2054==ERROR: AddressSanitizer: attempting double-free on 0x502000000010 in thread T0:

The rule that prevents this: create the object with std::make_shared or std::make_unique, so a raw pointer that could be wrapped twice never exists. When an object needs a shared_ptr to itself, for instance to register itself with something that must keep it alive, derive from std::enable_shared_from_this and call shared_from_this(), which shares the existing control block:

// shared_from_this.cpp - an object that hands out shared_ptrs to itself.
#include <iostream>
#include <memory>
#include <vector>

class Connection : public std::enable_shared_from_this<Connection> {
public:
    // Registers this connection with a list that must keep it alive.
    void register_with(std::vector<std::shared_ptr<Connection>>& active)
    {
        active.push_back(shared_from_this());   // shares the existing control block
    }
};

int main()
{
    std::vector<std::shared_ptr<Connection>> active;

    auto conn = std::make_shared<Connection>();
    conn->register_with(active);
    std::cout << "owned by a shared_ptr: use_count = " << conn.use_count() << '\n';

    Connection on_stack;                         // not owned by any shared_ptr
    try {
        on_stack.register_with(active);
    } catch (const std::bad_weak_ptr& e) {
        std::cout << "not owned by a shared_ptr: threw std::bad_weak_ptr (" << e.what() << ")\n";
    }
}

Output:

owned by a shared_ptr: use_count = 2
not owned by a shared_ptr: threw std::bad_weak_ptr (bad_weak_ptr)

shared_from_this() only works on an object that is already owned by a shared_ptr. Since C++17, calling it on any other object throws std::bad_weak_ptr rather than being undefined behaviour, so the stack object in this example produces an exception that can be caught.

std::weak_ptr: Breaking Reference Cycles

shared_ptr counts owners. If two objects own each other, each keeps the other’s count at one or more, and neither count reaches zero. The program below models a team that owns its lead, and a lead that owns a pointer back to the team:

// cycle_leak.cpp - two objects that own each other through std::shared_ptr
// are never destroyed.
#include <iostream>
#include <memory>

struct Employee;

struct Team {
    ~Team() { std::cout << "  ~Team\n"; }
    std::shared_ptr<Employee> lead;
};

struct Employee {
    ~Employee() { std::cout << "  ~Employee\n"; }
    std::shared_ptr<Team> team;           // owning back-pointer: creates a cycle
};

void build_team()
{
    auto team = std::make_shared<Team>();
    auto lead = std::make_shared<Employee>();
    team->lead = lead;
    lead->team = team;
    std::cout << "  team.use_count() = " << team.use_count()
              << ", lead.use_count() = " << lead.use_count() << '\n';
}   // both locals are destroyed here; each object is still owned by the other

int main()
{
    build_team();
    std::cout << "  build_team() returned\n";
}

Output:

  team.use_count() = 2, lead.use_count() = 2
build_team() returned

Neither destructor runs. When build_team() returns, its two local shared_ptrs are destroyed, each count drops from 2 to 1, and there is nothing left that can reduce them further. The memory stays allocated until the process exits.

You might expect a leak checker to catch this. LeakSanitizer did, sometimes. It decides what is reachable by scanning memory conservatively, so whether it reports these two objects depends on what happens to be left in registers and on the stack. Twenty runs of each build on the test machine:

Build (-g -fsanitize=address)Runs reporting the leak
GCC 13.3, -O020 of 20
GCC 13.3, -O220 of 20
Clang 18.1.3, -O00 of 20
Clang 18.1.3, -O220 of 20

Adding -fsanitize=undefined to the GCC -O0 build changed its result to 0 of 20. A cycle that a sanitizer reports in one configuration can pass silently in the next, which is a reason to design cycles out rather than rely on a tool to find them. When it does report, it calls both objects indirect leaks, because each one is reachable from the other.

The fix is to decide which side owns. Here the team owns its lead, and the lead only needs to refer to its team, so the back-pointer becomes a std::weak_ptr:

// cycle_fixed.cpp - the back-pointer becomes a std::weak_ptr, which observes
// the Team without keeping it alive.
#include <iostream>
#include <memory>

struct Employee;

struct Team {
    ~Team() { std::cout << "  ~Team\n"; }
    std::shared_ptr<Employee> lead;       // the team owns its lead
};

struct Employee {
    ~Employee() { std::cout << "  ~Employee\n"; }
    std::weak_ptr<Team> team;             // the lead only refers to the team

    void report() const
    {
        if (auto t = team.lock())         // a temporary owner, or null
            std::cout << "  team is alive, use_count while locked = " << t.use_count() << '\n';
        else
            std::cout << "  team no longer exists\n";
    }
};

int main()
{
    auto lead = std::make_shared<Employee>();
    {
        auto team = std::make_shared<Team>();
        team->lead = lead;
        lead->team = team;
        std::cout << "  team.use_count() = " << team.use_count()
                  << ", lead.use_count() = " << lead.use_count() << '\n';
        lead->report();
    }
    std::cout << "  left scope; expired() = " << std::boolalpha
              << lead->team.expired() << '\n';
    lead->report();
}

Output:

  team.use_count() = 1, lead.use_count() = 2
team is alive, use_count while locked = 2
~Team
left scope; expired() = true
team no longer exists
~Employee

team.use_count() is now 1: the weak_ptr does not count as an owner. lock() returns a temporary shared_ptr that keeps the team alive while report() uses it, which is why the count reads 2 inside that call. When the scope ends, ~Team runs, the weak_ptr reports expired() = true, and the next lock() returns null. A weak_ptr has no operator* or operator->; lock() and the null check are the only way in, which is what makes it safe to hold a reference to an object that might be gone.

Parent and child links in trees, observer lists, and caches are the usual places for this pattern: whichever side should not extend the other’s lifetime holds the weak_ptr.

Smart Pointers and Threads

The reference count in the control block is updated atomically, so separate shared_ptr objects that share ownership can be copied and destroyed on different threads without a lock. A single shared_ptr object written by two threads is a different matter. Both cases are in one program:

// threads.cpp - which std::shared_ptr operations are safe across threads.
//   threads          each thread copies the shared_ptr into its own local (safe)
//   threads assign   both threads assign to the same shared_ptr object (a data race)
#include <iostream>
#include <memory>
#include <string_view>
#include <thread>

int main(int argc, char** argv)
{
    auto config = std::make_shared<int>(1);

    if (argc == 2 && std::string_view(argv[1]) == "assign") {
        std::thread t1([&] { for (int i = 0; i < 1000; ++i) config = std::make_shared<int>(i); });
        std::thread t2([&] { for (int i = 0; i < 1000; ++i) config = std::make_shared<int>(-i); });
        t1.join();
        t2.join();
        std::cout << "shared assignment: done\n";
        return 0;
    }

    auto reader = [config] {                  // each closure holds its own copy
        for (int i = 0; i < 100000; ++i) {
            std::shared_ptr<int> local = config;
            (void)local;
        }
    };
    std::thread t1(reader), t2(reader);
    t1.join();
    t2.join();
    std::cout << "copies: done, use_count = " << config.use_count() << '\n';
}

Built with -fsanitize=thread, the default mode completes without a report:

copies: done, use_count = 2
exit status 0

The assign mode does not (excerpt):

WARNING: ThreadSanitizer: data race (pid=2068)
  Write of size 8 at 0x7fff86a50f10 by thread T2:
    #4 operator() src/threads.cpp:15 (th+0x31e3)
  Previous write of size 8 at 0x7fff86a50f10 by thread T1:
    #4 operator() src/threads.cpp:14 (th+0x2fb9)
ThreadSanitizer: reported 9 warnings
exit status 66

The report points at lines 14 and 15, the two assignments to config. Five runs produced 9 or 10 warnings each, depending on how the two loops interleaved. The count updates are atomic, but replacing the pointer stored in one shared_ptr object is an ordinary write, and two unsynchronized writes to the same object are a data race. This matches the thread-safety rules on cppreference’s shared_ptr page. The same applies to the object being pointed at: shared_ptr makes its lifetime safe to share, not its contents.

C++20 adds std::atomic<std::shared_ptr<T>> for the case where threads genuinely need to replace one shared pointer. Not every standard library provides it: libstdc++ 13 does, while libc++ 18 and the libc++ in Xcode 26.6 do not, and on those std::atomic<std::shared_ptr<int>> falls through to the general std::atomic<T> template and fails a static_assert because shared_ptr is not trivially copyable. The portable approach is to test the feature-test macro __cpp_lib_atomic_shared_ptr and fall back to a mutex:

// atomic_shared.cpp - two threads replacing one shared_ptr safely.
// C++20 std::atomic<std::shared_ptr<T>> where the standard library provides
// it (__cpp_lib_atomic_shared_ptr); otherwise a mutex around a plain shared_ptr.
				 
#include <iostream>
#include <memory>
#include <thread>
#include <utility>

#if defined(__cpp_lib_atomic_shared_ptr)
#include <atomic>

class SharedConfig {
public:
    explicit SharedConfig(std::shared_ptr<int> p) : ptr_(std::move(p)) {}
    void store(std::shared_ptr<int> p) { ptr_.store(std::move(p)); }
    std::shared_ptr<int> load() const { return ptr_.load(); }
    static constexpr const char* how = "std::atomic<std::shared_ptr>";
private:
    std::atomic<std::shared_ptr<int>> ptr_;
};

#else
#include <mutex>

class SharedConfig {
public:
    explicit SharedConfig(std::shared_ptr<int> p) : ptr_(std::move(p)) {}
    void store(std::shared_ptr<int> p)
    {
        std::lock_guard<std::mutex> lock(mutex_);
        ptr_.swap(p);                 // the old object is released after unlocking
    }
    std::shared_ptr<int> load() const
    {
        std::lock_guard<std::mutex> lock(mutex_);
        return ptr_;
    }
    static constexpr const char* how = "std::mutex";
private:
    mutable std::mutex mutex_;
    std::shared_ptr<int> ptr_;
};
#endif

int main()
{
    SharedConfig config(std::make_shared<int>(1));

    std::thread writer1([&] { for (int i = 0; i < 1000; ++i) config.store(std::make_shared<int>(i)); });
    std::thread writer2([&] { for (int i = 0; i < 1000; ++i) config.store(std::make_shared<int>(-i)); });
    writer1.join();
    writer2.join();

    std::shared_ptr<int> snapshot = config.load();
    std::cout << SharedConfig::how << ": done, last value is "
              << (*snapshot == 999 || *snapshot == -999 ? "from a final store" : "unexpected")
              << '\n';
}

Output (C++20 with ThreadSanitizer, no report; first libstdc++, then libc++ 18):

std::atomic<std::shared_ptr>: done, last value is from a final store
exit status 0
std::mutex: done, last value is from a final store
exit status 0

The two classes have the same interface, so the threads that use them do not change. The fallback gives up less than it might seem: a separate check of is_lock_free() on libstdc++ 13’s std::atomic<std::shared_ptr<int>> returned false, because that implementation uses an internal lock too. In the mutex version, store() swaps the new pointer in and lets the old object be released after the lock is dropped, so a destructor never runs while other threads wait.

A Pitfall unique_ptr Does Not Prevent: Recursive Destruction

Smart pointers stop leaks; they do not stop every lifetime bug. A linked list in which each node owns the next through a unique_ptr is a common first data structure, and its default destructor is recursive: destroying the head destroys its next, which destroys its next, one stack frame per node.

// long_list.cpp - a singly linked list whose nodes own the next node through
// std::unique_ptr. The default destructor destroys the chain recursively;
// clear() unlinks it one node at a time.
#include <cstdlib>
#include <iostream>
#include <memory>
#include <string_view>
#include <utility>

struct Node {
    explicit Node(int v) : value(v) {}
    int value;
    std::unique_ptr<Node> next;
};

class List {
public:
    List() = default;
    List(const List&) = delete;
    List& operator=(const List&) = delete;
    ~List() { if (iterative_) clear(); }   // otherwise head_'s destructor recurses

    explicit List(bool iterative) : iterative_(iterative) {}

    void push_front(int v)
    {
        auto node = std::make_unique<Node>(v);
        node->next = std::move(head_);
        head_ = std::move(node);
    }

    void clear() noexcept
    {
        while (head_)
            head_ = std::move(head_->next);   // the old head dies with an empty next
    }

private:
    std::unique_ptr<Node> head_;
    bool iterative_ = true;
};

int main(int argc, char** argv)
{
    if (argc != 3) {
        std::cerr << "usage: long_list <node-count> recursive|iterative\n";
        return 2;
    }
    const long count = std::strtol(argv[1], nullptr, 10);
    const bool iterative = std::string_view(argv[2]) == "iterative";

    {
        List list(iterative);
        for (long i = 0; i < count; ++i)
            list.push_front(static_cast<int>(i));
        std::cout << "built " << count << " nodes, destroying ("
                  << (iterative ? "iterative" : "recursive") << ")..." << std::endl;
    }
    std::cout << "destroyed" << std::endl;
}

Output (GCC 13.3, -O2, 8 MiB stack):

built 1000000 nodes, destroying (recursive)...
exit status 139
built 1000000 nodes, destroying (iterative)...
destroyed

Exit status 139 is a segmentation fault: the recursion ran out of stack. Under AddressSanitizer the same run reports stack-overflow inside Node::~Node(). The node count at which this happens depends on the compiler and the optimization level, because each level of recursion uses a different amount of stack. Bisecting on the test machine, with the default 8 MiB stack:

BuildLargest list destroyed without a crash
GCC 13.3, -O0about 58,000 nodes
GCC 13.3, -O2about 522,000 nodes
Clang 18.1.3, -O0about 65,000 nodes
Clang 18.1.3, -O2about 261,000 nodes

Two separate bisections agreed to within about 1,000 nodes for each build; the exact limit moves slightly between runs. A test with 100,000 nodes passes in the GCC -O2 build and crashes in the -O0 build of the same code. The fix is the clear() loop in the listing, which moves each node’s next into the head before the old head is destroyed, so every node dies with an empty next and the depth never exceeds one. The linked stack in this site’s stack implementation article uses the same loop for the same reason.

How to Pass Smart Pointers to Functions

A parameter type is a statement about ownership. The C++ Core Guidelines (rules R.32 to R.36 and F.7) give each form a specific meaning:

Parameter typeWhat it tells the callerExample use
T& or const T&Uses the object; does not care how it is ownedMost functions
T*Same, but “no object” is a valid argumentOptional input
std::unique_ptr<T> (by value)Takes ownership; the caller must std::moveAdding a node to a container
std::unique_ptr<T>&May replace the caller’s objectA function that rebuilds a component
std::shared_ptr<T> (by value)Keeps a share of ownershipStoring a callback target
const std::shared_ptr<T>&Might take a share; may decide not toConditional caching

To call a T& function with an object owned by a smart pointer, pass *p; to call a T* function, pass p.get(). The pointer from get() is only valid while some owner keeps the object alive, so do not store it beyond the call. For parameters that are not smart pointers, the C++ functions guide compares value, reference and pointer parameters with measured copy counts.

A std::unique_ptr<Base> that owns a derived object needs Base to have a virtual destructor; see the virtual destructor rule.

Which Smart Pointer Should You Use?

SituationUseWhy
One clear owner (most heap objects)std::unique_ptrSame size as a raw pointer; ownership is checked at compile time
A factory functionReturn std::unique_ptrThe caller can convert it to a shared_ptr if needed; the reverse is not possible
Several owners with no clear last userstd::shared_ptr, created with std::make_sharedOne allocation; counts updated atomically
A back-pointer, an observer or a cache entrystd::weak_ptrRefers without extending lifetime; lock() checks if the object still exists
A C handle such as FILE*std::unique_ptr with a stateless deleterReleases the handle on every path; no size cost
A dynamic arraystd::vector first; std::unique_ptr<T[]> if you need a fixed buffervector knows its size; unique_ptr<T[]> does not
Non-owning accessT& or T*Says nothing about ownership, because there is nothing to say

For the last two rows the better answer is often not a smart pointer at all. A std::vector manages its elements and knows its size, which a std::unique_ptr<T[]> does not.

Raw Pointers vs Smart Pointers

AspectOwning raw pointerSmart pointer
Releasing the objectdelete on every path outDestructor runs on every path out, including exceptions
OwnershipDocumented in comments, if at allEncoded in the type and checked by the compiler for unique_ptr
CopyingCopies the address; two owners result silentlyunique_ptr: compile error; shared_ptr: count incremented
Reference cyclesNot applicableshared_ptr cycles leak; break them with weak_ptr
Dangling accessPossible after deleteStill possible through get(), a reference to *p, or a second control block
Size (x86-64, libstdc++)8 bytesunique_ptr: 8 bytes with a stateless deleter; shared_ptr: 16 bytes plus a control block

Smart pointers remove the leak and double-delete cases that come from forgetting or repeating delete. They do not stop you from keeping a raw pointer or a reference to an object after its last owner has gone, and the two-control-block example shows that a shared_ptr used carelessly can still delete twice.

C++ Standard Versions and std::auto_ptr

StandardSmart-pointer change
C++11unique_ptr, shared_ptr, weak_ptr, make_shared, enable_shared_from_this; auto_ptr deprecated
C++14std::make_unique
C++17auto_ptr removed; shared_ptr<T[]>; shared_from_this() on a non-owned object throws std::bad_weak_ptr; weak_from_this()
C++20make_shared for arrays; make_unique_for_overwrite and make_shared_for_overwrite; std::atomic<std::shared_ptr<T>> (missing from libc++ 18 and Xcode 26.6’s libc++)
C++23std::unique_ptr and std::make_unique usable in constant expressions; std::out_ptr and std::inout_ptr for C functions that return a pointer through an out-parameter

std::auto_ptr was the C++98 attempt at an owning pointer. Its “copy” constructor quietly transferred ownership and left the source null, which made it unsafe in containers. It was deprecated in C++11 and removed in C++17. Removal from the standard did not mean removal from compilers: GCC 13.3 and Clang 18.1.3 with libstdc++ still compiled an auto_ptr program under -std=c++17 and -std=c++20, with a -Wdeprecated-declarations warning that says use 'std::unique_ptr' instead. Treat that warning as an error to fix, not one to silence. Replacing auto_ptr with unique_ptr usually needs only an added std::move wherever ownership was transferred by copying.

Most of the C++20 and C++23 rows were confirmed on the same toolchain: both compilers report __cpp_lib_shared_ptr_arrays, __cpp_lib_smart_ptr_for_overwrite and, under -std=c++23, __cpp_lib_constexpr_memory at 202202, and a static_assert on a function that creates a unique_ptr compiled under -std=c++23 and was rejected under -std=c++20. The exception is std::out_ptr: libstdc++ 13 does not define __cpp_lib_out_ptr, so it needs a newer standard library. The C++17 features article covers the other changes in that standard.

Key Takeaways

  • Use std::unique_ptr by default. With the default or any stateless deleter it is the size of a raw pointer, and the compiler rejects accidental copies.
  • Create objects with std::make_unique and std::make_shared. A raw pointer that never exists cannot be leaked on an early return or wrapped in two control blocks.
  • make_shared saves an allocation and delays a free. One 48-byte block instead of 32 + 24 bytes in the test, but that block stays reserved until the last weak_ptr is gone.
  • shared_ptr cycles leak, and leak checkers do not reliably say so. LeakSanitizer reported the same cycle in 20 of 20 runs in one build and 0 of 20 in another. Break cycles with weak_ptr by design.
  • Copies of a shared_ptr are thread-safe; one shared_ptr object is not. ThreadSanitizer flagged two threads assigning the same shared_ptr. Use a mutex or C++20 std::atomic<std::shared_ptr<T>>.
  • Pass T& or T* unless the function deals with ownership. A unique_ptr parameter means “I take it”; a shared_ptr parameter means “I keep a share”.
  • Long chains of unique_ptr destroy recursively. A 100,000-node list crashed a GCC -O0 build and not an -O2 build. Destroy long chains with a loop.

Frequently Asked Questions

Conclusion

Most of what goes wrong with smart pointers comes from treating them as a safer spelling of new and delete, when they are really a way of writing ownership down. Once the question is “who owns this?”, the choice usually follows: one owner gets a unique_ptr, shared owners get a shared_ptr, and anything that only needs to look gets a reference, a raw pointer, or a weak_ptr if the object might disappear.

The measurements here are the parts the type system does not show: the deleter that doubles a pointer’s size, the weak_ptr that holds a block in place, the cycle a sanitizer may or may not mention. More C++ tutorials are collected in the C++ programming section.

Source Code and Tests

smart-pointers

All programs on this page are in the memory/smart-pointers directory of the MYCPLUS C++ examples repository:

Build and test:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
bash tests/run_tests.sh "$PWD/build"
bash tests/sanitizers.sh g++

What the build checks. On Linux, with GCC and with Clang, it compiles every example with warnings treated as errors, runs it, and compares the output with the captured output on this page. A separate job confirms that the correct examples produce no AddressSanitizer, UndefinedBehaviorSanitizer or ThreadSanitizer report; that the raw-pointer leak, the double free, the recursive-destruction overflow and the shared-assignment race are each reported; and that copying a unique_ptr fails to compile. Linux with libc++, macOS and Windows jobs build with warnings as errors and compare the outputs that do not depend on the standard library. The shared_ptr cycle is checked by its output, since no destructor message should appear, rather than by LeakSanitizer. The crash thresholds, byte sizes and allocation sizes are not checked by any job; they were measured on one machine and would differ on another.

Scroll to Top