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
| Situation | Use |
|---|---|
| Single clear owner, resource returned from factory | unique_ptr |
| Multiple independent owners that outlive each other | shared_ptr |
| Observer: needs access but not ownership | weak_ptr or raw reference |
| Resource with custom cleanup (file, socket, lock) | unique_ptr with deleter |
| Doubly-linked structure, parent-child with back-pointer | shared_ptr forward, weak_ptr back |
| Polymorphic base class pointer | unique_ptr<Base> or shared_ptr<Base> |
| Container of heterogeneous owned objects | vector<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_ptronly when ownership is genuinely shared with independently-scoped owners make_unique/make_sharedare preferred: exception-safe and (formake_shared) a single allocationweak_ptrbreaks reference cycles and allows non-owning observation — always lock before use- Thread safety:
shared_ptrcopy/destroy is thread-safe; the pointed-to object is not - Custom deleters:
unique_ptrencodes in the type;shared_ptrtype-erases it in the control block - Factory functions should return
unique_ptr— callers can upgrade toshared_ptrif needed
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. shared_ptr vs unique_ptr: prefer unique_ptr by default; use shared_ptr for shared ownership. Reference counting cost, we… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- [Finding C++ Memory Leaks: Valgrind, AddressSanitizer, and](/en/blog/cpp-error-07-memory-leak-detection/
- [C++ Smart Pointers: unique_ptr, shared_ptr & Memory-Safe](/en/blog/cpp-smart-pointers/
- [C++ Memory Leaks](/en/blog/cpp-series-06-2-memory-leak/
- [C++ Shallow vs Deep Copy & Move Semantics Complete Guide](/en/blog/cpp-series-33-2-copy-move/
이 글에서 다루는 키워드 (관련 검색어)
C++, smart pointers, shared_ptr, unique_ptr, weak_ptr, memory management, RAII 등으로 검색하시면 이 글이 도움이 됩니다.