new로 만든 객체는 누군가 정확히 한 번 delete해야 한다. 함수 중간에 return이나 예외가 끼면 해제가 빠지고, 두 곳에서 해제하면 이중 해제가 된다. 스마트 포인터(smart pointer)는 이 “누가 언제 해제하는가”를 타입으로 표현한다. unique_ptr은 단독 소유, shared_ptr은 공동 소유, weak_ptr은 소유하지 않는 관찰을 뜻한다. 이 글에서는 g++ 13.3과 AddressSanitizer로 세 스마트 포인터의 동작, 할당 횟수와 크기, 순환 참조 누수, 같은 객체에 제어 블록이 두 개 생기는 실수를 확인한다.
이 시리즈의 다른 글
- C++ 고급 문법 (11) — 연산자 오버로딩(Operator Overloading)
- C++ 고급 문법 (12) — cout과 endl
- C++ 고급 문법 (14) — 형변환(Conversion)
- C++ 고급 문법 (15) — new 연산자(new Expression)
unique_ptr: 단독 소유
#include <cstdio>
#include <memory>
#include <utility>
struct Conn {
int id;
explicit Conn(int i) : id(i) { std::printf(" open #%d\n", id); }
~Conn() { std::printf(" close #%d\n", id); }
};
std::unique_ptr<Conn> connect(int id) { return std::make_unique<Conn>(id); } // 소유권 반환
void use(std::unique_ptr<Conn> c) { std::printf(" use #%d\n", c->id); } // 소유권 인수, 끝나면 해제
int main()
{
auto a = connect(1);
// auto b = a; // 복사 불가 (컴파일 에러)
auto b = std::move(a); // 이동만 가능
std::printf("a=%p b->id=%d\n", static_cast<void*>(a.get()), b->id);
use(std::move(b));
std::puts("after use");
auto c = connect(2);
c.reset(); // 명시적 해제
std::puts("end of main");
}$ g++ -std=c++20 -Wall -o u unique.cpp
$ ./u
open #1
a=(nil) b->id=1
use #1
close #1
after use
open #2
close #2
end of main
복사는 막혀 있고 std::move로만 소유권이 넘어간다. 값으로 받은 use()가 끝나는 순간 해제되는 것이 출력 순서에 드러난다.
사용자 정의 삭제자
#include <cstdio>
#include <memory>
int main()
{
auto close_file = [](std::FILE* f) { if (f) { std::puts(" fclose"); std::fclose(f); } };
std::unique_ptr<std::FILE, decltype(close_file)> f1(std::fopen("/etc/hostname", "r"), close_file);
std::unique_ptr<std::FILE, int (*)(std::FILE*)> f2(std::fopen("/etc/hostname", "r"), std::fclose);
std::printf("sizeof unique_ptr<int> = %zu\n", sizeof(std::unique_ptr<int>));
std::printf("sizeof with lambda deleter = %zu\n", sizeof(f1));
std::printf("sizeof with function ptr deleter = %zu\n", sizeof(f2));
std::printf("sizeof shared_ptr<int> = %zu\n", sizeof(std::shared_ptr<int>));
}$ g++ -std=c++20 -Wall -o d deleter.cpp
$ ./d
sizeof unique_ptr<int> = 8
sizeof with lambda deleter = 8
sizeof with function ptr deleter = 16
sizeof shared_ptr<int> = 16
fclose
상태 없는 람다 삭제자는 크기가 0이라 unique_ptr이 생 포인터와 같은 8바이트다. 함수 포인터 삭제자는 포인터를 하나 더 저장해 16바이트가 된다.
shared_ptr: 공동 소유
#include <cstdio>
#include <cstdlib>
#include <memory>
#include <new>
static int g_allocs = 0;
void* operator new(std::size_t n) { ++g_allocs; return std::malloc(n); }
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
struct Config { int values[4]{}; };
int main()
{
g_allocs = 0;
std::shared_ptr<Config> a(new Config); // 객체 1회 + 제어 블록 1회
std::printf("shared_ptr(new T) : %d allocs\n", g_allocs);
g_allocs = 0;
auto b = std::make_shared<Config>(); // 객체와 제어 블록을 한 번에
std::printf("make_shared<T>() : %d allocs\n", g_allocs);
auto c = b; // 참조 카운트만 증가
std::printf("use_count=%ld\n", b.use_count());
c.reset();
std::printf("use_count=%ld\n", b.use_count());
}$ g++ -std=c++20 -Wall -o s shared.cpp
$ ./s
shared_ptr(new T) : 2 allocs
make_shared<T>() : 1 allocs
use_count=2
use_count=1
shared_ptr은 참조 카운트를 담은 제어 블록(control block)을 따로 둔다. make_shared는 객체와 제어 블록을 한 번에 할당해 할당 횟수가 절반이다.
전달 비용
#include <chrono>
#include <cstdio>
#include <memory>
struct Data { long v = 1; };
__attribute__((noipa)) long by_value(std::shared_ptr<Data> p) { return p->v; } // 복사: 원자적 증감
__attribute__((noipa)) long by_cref(const std::shared_ptr<Data>& p) { return p->v; }
__attribute__((noipa)) long by_ref(const Data& d) { return d.v; } // 소유와 무관하면 이것
template <typename F>
void bench(const char* name, F f)
{
auto t0 = std::chrono::steady_clock::now();
long s = 0;
for (int i = 0; i < 50'000'000; ++i) s += f();
auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
std::printf("%-22s %7.1f ms (sum=%ld)\n", name, ms, s);
}
int main()
{
auto p = std::make_shared<Data>();
bench("shared_ptr by value", [&] { return by_value(p); });
bench("const shared_ptr&", [&] { return by_cref(p); });
bench("const Data&", [&] { return by_ref(*p); });
}$ g++ -std=c++20 -Wall -O2 -pthread -o ps passing.cpp
$ ./ps
shared_ptr by value 224.1 ms (sum=50000000)
const shared_ptr& 53.9 ms (sum=50000000)
const Data& 53.5 ms (sum=50000000)
값으로 넘기면 호출마다 참조 카운트를 원자적으로 증감해 4배 느리다. 소유권을 나눠 갖지 않는 함수는 const T&나 T*로 받는다.
순환 참조와 weak_ptr
#include <cstdio>
#include <memory>
struct Node {
const char* name;
std::shared_ptr<Node> next; // 서로를 shared_ptr로 가리킨다
explicit Node(const char* n) : name(n) {}
~Node() { std::fprintf(stderr, " ~Node %s\n", name); }
};
int main()
{
auto a = std::make_shared<Node>("a");
auto b = std::make_shared<Node>("b");
a->next = b;
b->next = a; // 순환
std::fprintf(stderr, "a.use_count=%ld b.use_count=%ld\n", a.use_count(), b.use_count());
} // 지역 변수가 사라져도 카운트가 1로 남는다$ g++ -std=c++20 -Wall -g -fsanitize=address -o cy cycle.cpp
$ ./cy # LeakSanitizer 보고 중 요약만 발췌
a.use_count=2 b.use_count=2
ERROR: LeakSanitizer: detected memory leaks
SUMMARY: AddressSanitizer: 80 byte(s) leaked in 2 allocation(s).
서로를 shared_ptr로 붙잡으면 지역 변수가 사라져도 카운트가 0이 되지 않아 소멸자가 불리지 않는다. 한쪽 방향을 weak_ptr로 바꾸면 해결된다.
#include <cstdio>
#include <memory>
struct Node {
const char* name;
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // 역방향은 소유하지 않는다
explicit Node(const char* n) : name(n) {}
~Node() { std::fprintf(stderr, " ~Node %s\n", name); }
};
int main()
{
std::weak_ptr<Node> observer;
{
auto a = std::make_shared<Node>("a");
auto b = std::make_shared<Node>("b");
a->next = b;
b->prev = a;
observer = b;
if (auto p = b->prev.lock()) std::fprintf(stderr, "b->prev = %s\n", p->name); // 사용 시 lock()
std::fprintf(stderr, "a.use_count=%ld b.use_count=%ld\n", a.use_count(), b.use_count());
}
std::fprintf(stderr, "observer.expired=%d\n", observer.expired());
}$ g++ -std=c++20 -Wall -g -fsanitize=address -o wk weak.cpp
$ ./wk
b->prev = a
a.use_count=1 b.use_count=2
~Node a
~Node b
observer.expired=1
weak_ptr은 카운트를 올리지 않으므로 a의 카운트는 1이다. 쓸 때는 lock()으로 임시 shared_ptr을 얻고, 대상이 사라졌으면 빈 포인터가 나온다.
this로 shared_ptr을 만들 때
#include <cstdio>
#include <memory>
#include <vector>
struct Session;
std::vector<std::shared_ptr<Session>> g_registry;
struct Session {
void register_self()
{
g_registry.push_back(std::shared_ptr<Session>(this)); // this로 새 제어 블록을 만든다
}
};
int main()
{
auto s = std::make_shared<Session>();
s->register_self();
std::fprintf(stderr, "s.use_count=%ld\n", s.use_count()); // 2가 아니라 1
} // 두 제어 블록이 같은 객체를 각각 delete$ g++ -std=c++20 -Wall -g -fsanitize=address -o tw twice.cpp
$ ./tw # ASan 보고 중 에러 종류만 발췌
s.use_count=1
ERROR: AddressSanitizer: attempting free on address which was not malloc()-ed
생 포인터로 shared_ptr을 만들면 기존 제어 블록을 모르고 새로 만든다. 같은 객체를 두 제어 블록이 각각 해제하고, 그중 하나는 make_shared 할당 블록의 중간 주소를 해제하려 한다.
#include <cstdio>
#include <memory>
#include <vector>
struct Session;
std::vector<std::shared_ptr<Session>> g_registry;
struct Session : std::enable_shared_from_this<Session> {
void register_self()
{
g_registry.push_back(shared_from_this()); // 기존 제어 블록을 공유
}
};
int main()
{
auto s = std::make_shared<Session>();
s->register_self();
std::fprintf(stderr, "s.use_count=%ld\n", s.use_count());
Session raw; // shared_ptr이 소유하지 않는 객체
try { raw.register_self(); }
catch (const std::bad_weak_ptr& e) { std::fprintf(stderr, "bad_weak_ptr: %s\n", e.what()); }
}$ g++ -std=c++20 -Wall -g -fsanitize=address -o sft sft.cpp
$ ./sft
s.use_count=2
bad_weak_ptr: bad_weak_ptr
enable_shared_from_this를 상속하면 shared_from_this()가 기존 제어 블록을 공유한다. shared_ptr이 소유하지 않는 객체에서 부르면 C++17부터 std::bad_weak_ptr 예외가 난다.
매개변수 선택 기준
| 함수가 하는 일 | 매개변수 |
|---|---|
| 객체를 사용만 함 | T&, const T&, T*(없을 수 있으면) |
| 소유권을 가져감 | std::unique_ptr<T> (값) |
| 소유권을 공유해 보관함 | std::shared_ptr<T> (값, 받은 뒤 std::move) |
스마트 포인터 자체를 바꿈(reset 등) | std::unique_ptr<T>&, std::shared_ptr<T>& |
| 나중에 살아 있으면 사용 | std::weak_ptr<T> |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
같은 생 포인터로 shared_ptr 두 번 생성 | 제어 블록 두 개, 이중 해제 | make_shared와 shared_from_this() |
양방향 shared_ptr | 순환 참조 누수 | 역방향·부모 방향은 weak_ptr |
shared_ptr을 습관적으로 값 전달 | 원자적 카운트 연산 비용 | 사용만 하면 참조로 전달 |
큰 객체를 make_shared로 생성 + weak_ptr 장기 보유 | 객체와 제어 블록이 한 덩어리라 weak_ptr이 남아 있는 동안 메모리가 반환되지 않음 | 이 경우만 shared_ptr(new T) |
| 배열 | unique_ptr<T>는 delete로 해제 | unique_ptr<T[]>나 std::vector |
get()으로 꺼낸 포인터를 보관 | 소유자 해제 후 dangling | 보관이 필요하면 스마트 포인터로 보관 |
마무리
- 기본은
unique_ptr이며 생 포인터와 크기·비용이 같다. shared_ptr은 제어 블록과 원자적 카운트 비용이 있으므로 공동 소유가 필요할 때만 쓰고make_shared로 만든다.- 순환은
weak_ptr로 끊고,this기반 공유는enable_shared_from_this로 한다.