C++ 고급 문법 (13) — 스마트 포인터(Smart Pointer)

new로 만든 객체는 누군가 정확히 한 번 delete해야 한다. 함수 중간에 return이나 예외가 끼면 해제가 빠지고, 두 곳에서 해제하면 이중 해제가 된다. 스마트 포인터(smart pointer)는 이 “누가 언제 해제하는가”를 타입으로 표현한다. unique_ptr은 단독 소유, shared_ptr은 공동 소유, weak_ptr은 소유하지 않는 관찰을 뜻한다. 이 글에서는 g++ 13.3과 AddressSanitizer로 세 스마트 포인터의 동작, 할당 횟수와 크기, 순환 참조 누수, 같은 객체에 제어 블록이 두 개 생기는 실수를 확인한다.

이 시리즈의 다른 글

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로 한다.

참고

답글 남기기