본문으로 건너뛰기 C++ shared_ptr vs unique_ptr: Smart Pointer Choice Complete

C++ shared_ptr vs unique_ptr: Smart Pointer Choice Complete

C++ shared_ptr vs unique_ptr: Smart Pointer Choice Complete

이 글의 핵심

shared_ptr vs unique_ptr: prefer unique_ptr by default; use shared_ptr for shared ownership. Reference counting cost, weak_ptr for cycles, and performance-minded patterns.

Why Smart Pointers?

Raw pointers in C++ do not communicate ownership. Who frees the memory? Who is allowed to use the pointer after a function call? These questions cause memory leaks, use-after-free, and double-free bugs.

Smart pointers from <memory> make ownership explicit and automatic:

// Raw pointer — ownership unclear
int* raw = new int(42);
delete raw;  // remember to delete, no double-delete, no forget

// unique_ptr — single owner, deleted automatically
std::unique_ptr<int> u = std::make_unique<int>(42);
// deleted when u goes out of scope — no manual delete

// shared_ptr — reference counted, deleted when last owner is gone
std::shared_ptr<int> s = std::make_shared<int>(42);
auto s2 = s;  // refcount = 2
// deleted when both s and s2 go out of scope

The difference between them is ownership semantics, not just syntax.


Ownership Models

unique_ptr: Exclusive Ownership

unique_ptr expresses that exactly one object owns the resource. You cannot copy it — only move it. When the unique_ptr is destroyed, the object is deleted.

#include <memory>
#include <iostream>

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

int main() {
    auto w = std::make_unique<Widget>(1);    // created
    // auto w2 = w;                          // compile error — cannot copy

    auto w2 = std::move(w);                  // ownership transferred
    // w is now null — w2 owns the Widget

    if (!w) std::cout << "w is empty\n";    // w is empty
    std::cout << "w2 owns Widget " << w2->id << '\n';
}
// Widget 1 destroyed — when w2 goes out of scope

This is the most common smart pointer — use it whenever a resource has a single clear owner.

shared_ptr: Shared Ownership

shared_ptr allows multiple owners. A reference count in the control block tracks how many shared_ptr objects point to the resource. The resource is deleted when the last shared_ptr to it is destroyed.

#include <memory>
#include <iostream>

int main() {
    auto shared = std::make_shared<Widget>(2);  // refcount = 1
    std::cout << "count: " << shared.use_count() << '\n';  // 1

    {
        auto copy1 = shared;  // refcount = 2
        auto copy2 = shared;  // refcount = 3
        std::cout << "count: " << shared.use_count() << '\n';  // 3
    }  // copy1 and copy2 destroyed — refcount = 1

    std::cout << "count: " << shared.use_count() << '\n';  // 1
}
// Widget 2 destroyed — last owner (shared) goes out of scope

weak_ptr: Non-Owning Observer

weak_ptr observes a shared_ptr-managed object without participating in ownership. It does not keep the object alive.

#include <memory>
#include <iostream>

int main() {
    std::shared_ptr<Widget> owner = std::make_shared<Widget>(3);
    std::weak_ptr<Widget> observer = owner;  // does not increment refcount

    std::cout << "owner count: " << owner.use_count() << '\n';  // 1

    // To use a weak_ptr, lock() it — returns shared_ptr or empty
    if (auto locked = observer.lock()) {
        std::cout << "Object still alive: " << locked->id << '\n';
    }

    owner.reset();  // Widget destroyed

    if (observer.expired()) {
        std::cout << "Object has been destroyed\n";
    }
}

Overhead Comparison

unique_ptr has zero overhead compared to a raw pointer — it is the same size and often compiles to identical machine code:

unique_ptr<T>:  [T*]          — same size as a raw pointer
                              — no heap allocation for ownership tracking
                              — custom deleter encoded in the type

shared_ptr<T>:  [T*, control*] — two pointers
                               — control block: refcount (atomic) + weakcount + deleter
                               — control block is a separate heap allocation unless make_shared

make_shared is more efficient than shared_ptr(new T(...)) because it allocates the object and control block in a single allocation:

// Two allocations: one for Widget, one for control block
std::shared_ptr<Widget> p1(new Widget(1));

// One allocation: Widget and control block together
std::shared_ptr<Widget> p2 = std::make_shared<Widget>(2);

The refcount updates in shared_ptr use atomic operations, which have measurable cost on multicore systems. In a tight loop copying and destroying shared_ptr, this can be slower than a similar unique_ptr workload. Profile before assuming it matters in your case.


Usage Patterns

Passing unique_ptr to Functions

// Sink: function takes ownership — pass by value, caller must move
void consume(std::unique_ptr<Widget> w) {
    std::cout << "consuming " << w->id << '\n';
    // w deleted when function returns
}

// Borrow: function uses but doesn't own — pass raw pointer or const reference
void inspect(const Widget& w) {
    std::cout << "inspecting " << w.id << '\n';
}

void inspectPtr(const Widget* w) {
    if (w) std::cout << "inspecting " << w->id << '\n';
}

int main() {
    auto w = std::make_unique<Widget>(10);

    inspect(*w);          // borrow by reference — w still owns
    inspectPtr(w.get());  // borrow by raw pointer — w still owns

    consume(std::move(w));  // transfer ownership — w is now null
    // w is null here
}

Factory Functions — Return unique_ptr

Factories that create objects should return unique_ptr. The caller can either keep it as unique_ptr, or upgrade to shared_ptr if needed:

std::unique_ptr<Widget> makeWidget(int id) {
    return std::make_unique<Widget>(id);  // clear: factory gives you ownership
}

int main() {
    // Keep as unique_ptr
    auto w = makeWidget(1);

    // Upgrade to shared_ptr if shared ownership is needed later
    std::shared_ptr<Widget> shared = makeWidget(2);
}

Custom Deleters

Both smart pointer types support custom deletion logic:

#include <cstdio>
#include <memory>

// unique_ptr with custom deleter — deleter is part of the type
auto fileCloser = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(fileCloser)> file(fopen("data.txt", "r"), fileCloser);

// shared_ptr with custom deleter — deleter is type-erased, not in the type
std::shared_ptr<FILE> sharedFile(fopen("data.txt", "r"), [](FILE* f) {
    if (f) fclose(f);
});

The difference: unique_ptr’s deleter is part of the template type (visible to callers), while shared_ptr’s deleter is stored in the control block and erased from the type. shared_ptr is more flexible here — you can store different deleters in the same shared_ptr<FILE> container.


Fixing Circular Reference with weak_ptr

The most common shared_ptr bug is a reference cycle. Two objects pointing to each other with shared_ptr keep each other alive forever — memory leak:

#include <memory>
#include <iostream>

struct Node {
    int value;
    std::shared_ptr<Node> next;   // shared_ptr to next
    std::shared_ptr<Node> prev;   // shared_ptr to previous — PROBLEM

    Node(int v) : value(v) {}
    ~Node() { std::cout << "Node " << value << " destroyed\n"; }
};

int main() {
    auto n1 = std::make_shared<Node>(1);
    auto n2 = std::make_shared<Node>(2);

    n1->next = n2;
    n2->prev = n1;  // cycle: n1 → n2 → n1

    // End of main: n1 refcount = 1 (n2->prev), n2 refcount = 1 (n1->next)
    // Neither reaches zero — LEAK. Neither destructor is called.
}

Fix: use weak_ptr on the back edge (the one that doesn’t need to keep the object alive):

struct Node {
    int value;
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // weak_ptr — no ownership

    Node(int v) : value(v) {}
    ~Node() { std::cout << "Node " << value << " destroyed\n"; }
};

int main() {
    auto n1 = std::make_shared<Node>(1);
    auto n2 = std::make_shared<Node>(2);

    n1->next = n2;
    n2->prev = n1;  // weak_ptr — does not increment n1's refcount

    // End of main: n1 refcount = 1 (stack), n2 refcount = 1 (n1->next)
    // n1 destroyed: refcount drops to 0 → Node 1 destroyed
    // Then n1->next released: n2 refcount drops to 0 → Node 2 destroyed
}
// Output:
// Node 1 destroyed
// Node 2 destroyed

Thread Safety

shared_ptr guarantees that reference count modifications (copying and destroying shared_ptr instances) are thread-safe. But the pointed-to object is not:

#include <memory>
#include <thread>
#include <mutex>

auto shared = std::make_shared<int>(0);
std::mutex mtx;

void increment() {
    // shared_ptr copy/destroy is thread-safe
    auto local = shared;  // safe — atomic refcount increment

    // BUT: modifying the pointed-to int is NOT thread-safe without synchronization
    std::lock_guard<std::mutex> lock(mtx);
    (*local)++;
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << *shared << '\n';  // 2 — correct with mutex
}

Selection Guide

SituationUse
Single clear owner, resource returned from factoryunique_ptr
Multiple independent owners that outlive each othershared_ptr
Observer: needs access but not ownershipweak_ptr or raw reference
Resource with custom cleanup (file, socket, lock)unique_ptr with deleter
Doubly-linked structure, parent-child with back-pointershared_ptr forward, weak_ptr back
Polymorphic base class pointerunique_ptr<Base> or shared_ptr<Base>
Container of heterogeneous owned objectsvector<unique_ptr<Base>>

Default rule: reach for unique_ptr first. Upgrade to shared_ptr only when the use case genuinely requires shared ownership — when it’s difficult to define a single owner with a clear lifetime.


enable_shared_from_this

When an object managed by shared_ptr needs to create a new shared_ptr to itself (common in async callbacks), use enable_shared_from_this:

#include <memory>
#include <iostream>

class Session : public std::enable_shared_from_this<Session> {
public:
    void start() {
        // shared_from_this() returns a shared_ptr to this — safe
        auto self = shared_from_this();
        // pass self to async callback to keep Session alive
        doAsync([self]() {
            std::cout << "async work on Session\n";
        });
    }

    template<typename F>
    void doAsync(F f) { f(); }
};

int main() {
    auto session = std::make_shared<Session>();
    session->start();
}

Never call shared_from_this() in a constructor — the shared_ptr managing the object doesn’t exist yet.


Key Takeaways

  • Default to unique_ptr — zero overhead, clear ownership, move-only semantics prevent accidental sharing
  • Use shared_ptr only when ownership is genuinely shared with independently-scoped owners
  • make_unique / make_shared are preferred: exception-safe and (for make_shared) a single allocation
  • weak_ptr breaks reference cycles and allows non-owning observation — always lock before use
  • Thread safety: shared_ptr copy/destroy is thread-safe; the pointed-to object is not
  • Custom deleters: unique_ptr encodes in the type; shared_ptr type-erases it in the control block
  • Factory functions should return unique_ptr — callers can upgrade to shared_ptr if needed

Frequently Asked Questions (FAQ)

Q. When would I use this in practice?

A. shared_ptr vs unique_ptr: prefer unique_ptr by default; use shared_ptr for shared ownership.

Q. What should I read before this?

A. Follow the previous article or related articles links at the bottom of each post to learn in sequence. See the C++ series index for the full picture.

Q. Where can I study this more deeply?

A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers C++, smart pointers, shared_ptr, unique_ptr, weak_ptr, memory management, RAII.