C++ 스마트 포인터와 이동 의미론, 실측으로 이해하기

C++ 강좌 4. unique_ptr·shared_ptr·weak_ptr, 소멸자 출력으로 보는 순환 누수, std::move 의 정체, noexcept 유무에 따른 vector 복사 횟수와 시간 실측.

시리즈: C++ 강좌 4/6 (중급). 클래스, 생성자, RAII 까지는 아는데(3강) 코드 리뷰 때마다 std::unique_ptr, std::move, && 를 지적받는 분을 위한 글입니다. 다 읽으면 상황에 맞는 스마트 포인터를 고르고, shared_ptr 순환을 소멸자 출력과 macOS leaks 로 찾고, 이동 후 객체에 무엇이 남는지 설명하고, noexcept 하나 빠뜨려서 std::vector 가 3 배 느려진 이유를 말할 수 있습니다.

게임 회사나 증권사 C++ 면접에서 단골로 나오는 질문이 "이동 생성자에 noexcept 를 왜 붙이나요?", "shared_ptr 로 누수가 날 수 있나요?" 입니다. 외워서 답하기는 쉽지만, 직접 숫자로 본 적이 있는 사람은 많지 않습니다. 이 글은 그 답을 카운터와 시간으로 보여 줍니다. 아래는 모두 Apple Silicon Mac(M1 Pro), macOS 26, Apple clang 21.0.0 + libc++ 에서 clang++ -std=c++23 -Wall -Wextra -pedantic, 시간 측정은 -O2 로 실행했습니다. GCC, MSVC, Linux, Valgrind 는 여기서 돌리지 않았습니다.

스마트 포인터 세 종류를 프로그램 하나로

스마트 포인터는 힙 객체를 소유하고 자기 소멸자에서 지워 주는 작은 객체입니다(3강의 RAII 그대로입니다). 표준 라이브러리에는 세 가지가 있습니다.

  • std::unique_ptr<T>: 소유자는 정확히 하나. 복사는 안 되고 이동만 된다.
  • std::shared_ptr<T>: 참조 카운트로 공유. 마지막 소유자가 지운다.
  • std::weak_ptr<T>: shared_ptr 객체를 소유하지 않고 지켜본다. lock() 하면 아직 살아 있을 때만 shared_ptr 를 준다.
#include <cstdio>
#include <memory>
#include <string>

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

std::unique_ptr<Res> make(const char *n) { return std::make_unique<Res>(n); }

int main() {
    std::printf("sizeof raw=%zu unique_ptr=%zu shared_ptr=%zu weak_ptr=%zu\n",
                sizeof(Res *), sizeof(std::unique_ptr<Res>),
                sizeof(std::shared_ptr<Res>), sizeof(std::weak_ptr<Res>));
    {
        std::printf("block 1\n");
        auto a = make("A");
        auto b = std::move(a);              // ownership moves; a is now null
        std::printf("  a=%s b=%s\n", a ? "set" : "null", b ? b->name.c_str() : "null");
    }
    std::printf("block 2\n");
    std::shared_ptr<Res> s1 = std::make_shared<Res>("S");
    {
        auto s2 = s1;
        std::printf("  use_count=%ld\n", s1.use_count());
        std::weak_ptr<Res> w = s1;
        std::printf("  weak use_count=%ld expired=%d\n", w.use_count(), w.expired());
    }
    std::printf("  use_count=%ld\n", s1.use_count());
    std::weak_ptr<Res> w = s1;
    s1.reset();
    std::printf("  after reset: expired=%d lock()=%s\n", w.expired(), w.lock() ? "obj" : "nullptr");
    std::printf("end of main\n");
}
sizeof raw=8 unique_ptr=8 shared_ptr=16 weak_ptr=16
block 1
  acquire A
  a=null b=A
  release A
block 2
  acquire S
  use_count=2
  weak use_count=2 expired=0
  use_count=1
  release S
  after reset: expired=1 lock()=nullptr
end of main

이 출력에서 읽을 것:

  1. unique_ptr 는 raw 포인터와 똑같이 8 바이트입니다. 기본 deleter 라면 크기 비용이 0 입니다. shared_ptr 와 weak_ptr 는 16 바이트로, 객체를 가리키는 포인터와 카운트를 담은 제어 블록을 가리키는 포인터 두 개입니다.
  2. release A 는 main 끝이 아니라 block 1 의 닫는 중괄호에서 찍혔습니다. delete 는 한 번도 쓰지 않았습니다.
  3. weak_ptr 는 use_count 를 올리지 않습니다(s1, s2, w 가 있어도 2). 마지막 소유자가 reset 한 뒤 lock() 은 매달린 포인터가 아니라 nullptr 를 돌려줍니다.

unique_ptr 를 복사하려 하면 컴파일 오류가 나는데, 이건 고마운 오류입니다.

copyerr.cpp:4:10: error: call to implicitly-deleted copy constructor of 'unique_ptr<int>'
    4 |     auto b = a;
      |          ^   ~
.../c++/v1/__memory/unique_ptr.h:209:55: note: copy constructor is implicitly deleted because 'unique_ptr<int>' has a user-declared move constructor

의도를 분명히 쓰면 됩니다. 소유권을 넘기려면 auto b = std::move(a);, 빌리기만 하려면 auto& b = a; 입니다.

make_shared 는 할당을 두 번이 아니라 한 번 합니다. 전역 operator new 를 횟수를 세는 버전으로 바꿔서 확인했습니다.

make_unique:            1 allocation(s)
shared_ptr<T>(new T):   2 allocation(s)
make_shared:            1 allocation(s)

shared_ptr<T>(new T) 는 객체와 제어 블록을 따로 할당하고, make_shared 는 한 블록에 합칩니다. make_unique / make_shared 를 쓰면 코드에 맨 new 도 남지 않습니다. (make_unique 는 C++14 부터입니다. -std=c++11 에서는 no member named 'make_unique' in namespace 'std' 가 났습니다.)

shared_ptr 순환: 세어 보면 보이는 누수

참조 카운트에는 사각지대가 하나 있습니다. A 가 B 를, B 가 A 를 소유하면 어느 쪽 카운트도 0 이 되지 않습니다.

#include <cstdio>
#include <memory>
#include <string>

static int alive = 0;

struct Person {
    std::string name;
    std::shared_ptr<Person> partner;     // strong link: the bug
    // std::weak_ptr<Person> partner;    // weak link: the fix
    explicit Person(std::string n) : name(std::move(n)) { ++alive; std::printf("ctor %s\n", name.c_str()); }
    ~Person() { --alive; std::printf("dtor %s\n", name.c_str()); }
};

int main() {
    {
        auto a = std::make_shared<Person>("alice");
        auto b = std::make_shared<Person>("bob");
        a->partner = b;
        b->partner = a;
        std::printf("use_count a=%ld b=%ld\n", a.use_count(), b.use_count());
    }
    std::printf("after scope: alive=%d\n", alive);
}
ctor alice
ctor bob
use_count a=2 b=2
after scope: alive=2

생성자 두 번, 소멸자 0 번. 스코프가 끝나면 a 와 b 가 사라지면서 카운트가 2 에서 1 로 내려갈 뿐이고, 두 객체는 서로를 영원히 살려 둡니다. 죽지도 않고 ASan 도 조용하니 리뷰를 통과합니다. 위처럼 소멸자 출력을 세는 것이 가장 싸게 쓸 수 있는 테스트입니다.

macOS 에 들어 있는 leaks 는 이 패턴을 이름으로 짚어 줍니다(일부 생략).

$ leaks --atExit -- ./cycle
Process 42649: 2 leaks for 160 total leaked bytes.
    2 (160 bytes) ROOT CYCLE: <std::__shared_ptr_emplace<Person> 0x726c24050> [80]
       1 (80 bytes) ROOT CYCLE: <std::__shared_ptr_emplace<Person> 0x726c240a0> [80]
          CYCLE BACK TO <std::__shared_ptr_emplace<Person> 0x726c24050> [80]

하나에 80 바이트인 것은 제어 블록과 Person 이 할당 한 번에 들어 있기 때문입니다(앞의 make_shared 구조). 주석 두 줄을 맞바꿔 partner 를 weak_ptr 로 하면 이렇게 됩니다.

ctor alice
ctor bob
use_count a=1 b=1
dtor bob
dtor alice
after scope: alive=0
Process 42675: 0 leaks for 0 total leaked bytes.

원칙은 소유 관계는 트리로입니다. 부모→자식은 shared_ptr(또는 unique_ptr), 자식→부모, 관찰자→대상, 캐시→항목은 weak_ptr 나 일반 참조로 둡니다. iOS 앱을 만들어 봤다면 Swift 의 순환 참조와 weak var 와 같은 이야기입니다. Swift ARC 글에서 다른 언어로 같은 누수를 볼 수 있습니다.

판단 그림. 먼저 '이 코드가 객체를 소유해야 하나?'. 아니면 참조나 raw 포인터 매개변수. 그렇다면 '소유자가 정확히 하나인가?'. 그렇다면 unique_ptr, 아니면 shared_ptr. shared_ptr 에서 객체를 살려 두면 안 되는 역방향 링크나 관찰자는 weak_ptr 을 쓰고, shared_ptr 로 양방향 연결하면 순환 누수가 된다.
힙 객체의 소유자 고르기. 소유 관계는 트리로 만들고, 거꾸로 가는 링크는 weak_ptr 로.

std::move 는 아무것도 옮기지 않는다

std::move(x) 는 캐스트일 뿐입니다. x 를 rvalue(T&&)로 바꿔서 오버로드 해석이 이동 생성자나 이동 대입을 고르게 할 뿐입니다. 실제로 "옮기는" 일은 그 생성자가 하고, 대개 안쪽 포인터를 가져가고 원본을 비웁니다.

std::string s = "a string long enough to live on the heap";
std::string t = std::move(s);
std::printf("s=\"%s\" size=%zu | t size=%zu\n", s.c_str(), s.size(), t.size());

std::vector<int> v(5, 1);
std::vector<int> w = std::move(v);
v.push_back(3);                          // valid: reuse after move is allowed

const std::string c = "const string that cannot be moved from at all";
std::string d = std::move(c);            // silently copies
s="" size=0 | t size=40
small="" size=0
v.size=0 w.size=5
after push_back v.size=1
c still="const..." size=45
  • 이동된 객체는 유효하지만 명시되지 않은 상태입니다. libc++ 에서는 긴 문자열, 5 글자짜리 짧은 문자열, vector 모두 비어 있었지만 표준이 약속하는 건 "유효함"뿐입니다. 대입하거나 소멸시키기만 하고 내용을 읽지 마세요.
  • const 객체에 std::move 를 쓰면 -Wall -Wextra -pedantic 에서도 경고 없이 컴파일되고 복사됩니다. const std::string&& 는 이동 생성자에 묶일 수 없어서 복사 생성자가 선택되기 때문입니다.
  • 이동 후 사용은 clang++ --analyze 가 잡았습니다: Method called on moved-from object 's' of type 'std::basic_string' [cplusplus.Move]. 객체 세 개에 보고 세 건입니다.

반대로, 컴파일러가 더 잘하는 곳에 std::move 를 덧붙이는 실수도 있습니다.

std::vector<int> build() {
    std::vector<int> v(1000, 7);
    return std::move(v);                 // looks faster, is not
}
movedfrom.cpp:8:12: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
movedfrom.cpp:8:12: note: remove std::move call here

return v; 면 호출한 쪽 변수에 결과를 바로 만들 수 있습니다(복사 생략). return v; 라고 쓰세요.

복사와 이동, 실측

차이는 얼마나 될까요. 무작위 영문자 32 글자(std::mt19937, 시드 42) 문자열 100 만 개짜리 std::vector<std::string> 을 만들어 복사와 이동을 했습니다. -O2, 7 회 중앙값입니다.

auto t0 = clk::now();
std::vector<std::string> copy = src;               // copy: 1,000,001 heap allocations
auto t1 = clk::now();
std::vector<std::string> moved = std::move(copy);  // move: 3 pointers change hands
auto t2 = clk::now();
copy: median 16.97 ms
move: median 0 ns

"0 ns" 는 진짜 값이 아닙니다. 이 Mac 의 steady_clock 은 약 42 ns 단위로 움직여서(now() 를 연달아 두 번 부르면 42 와 0 이 나왔습니다) 이동이 시계 해상도보다 짧았을 뿐입니다. 그래서 같은 vector 를 컴파일러 배리어를 끼워 1,000 만 번 왕복시키고 나눴습니다.

1.56 ns per move assignment, a.size=1000000

복사 약 17 ms, 이동 약 1.6 ns. 대략 1,000 만 배 차이이고, 이동 시간은 원소 수와 상관없습니다. 복사할 때 문자열마다 힙 할당이 일어나는 이유는 32 글자가 문자열 내부의 작은 버퍼에 들어가지 않기 때문입니다(그 경계는 6강에서 잽니다).

noexcept 와 vector 확장: 부탁하지 않은 복사

std::vector 는 용량이 모자라면 더 큰 블록을 할당하고 기존 원소를 옮겨 담습니다. 이때 이동을 쓰는 것은 이동 생성자가 noexcept 일 때뿐이고, 아니면 복사합니다. 이유는 강한 예외 보장입니다. 이동이 중간에 예외를 던지면 원래 원소는 이미 속이 비어 되돌릴 수 없지만, 복사는 실패해도 원래 블록이 멀쩡합니다.

noexcept 만 다른 타입으로 세어 봤습니다.

static long copies = 0, moves = 0;

template <bool NoExcept>
struct Item {
    std::string s;
    explicit Item(int i) : s(40, char('a' + i % 26)) {}
    Item(const Item &o) : s(o.s) { ++copies; }
    Item(Item &&o) noexcept(NoExcept) : s(std::move(o.s)) { ++moves; }
    Item &operator=(const Item &) = default;
    Item &operator=(Item &&) = default;
};
// for (int i = 0; i < n; ++i) v.push_back(Item<NoExcept>(i));
move without noexcept  n=1000     copies=1023      moves=1000      (nothrow_move=0)
move with noexcept     n=1000     copies=0         moves=2023      (nothrow_move=1)
move without noexcept  n=1000000  copies=1048575   moves=1000000   (nothrow_move=0)
move with noexcept     n=1000000  copies=0         moves=2048575   (nothrow_move=1)
median of 7: without noexcept 43.32 ms, with noexcept 14.57 ms

(앞 네 줄은 시간 열을 뺐습니다.) 첫 줄의 이동 1,000 번은 push_back(Item(i)) 가 임시 객체를 옮긴 것이라 어느 쪽에서나 생깁니다. 복사 1,023 번이 재배치입니다. libc++ 는 용량을 1, 2, 4, …, 1024 로 두 배씩 늘리므로 1 + 2 + … + 512 = 1,023 개를 옮겼습니다. noexcept 가 없으면 그 하나하나가 할당을 동반한 문자열 복사가 됩니다. 원소 100 만 개면 복사 1,048,575 번, 시간은 약 3 배입니다.

막대그래프. noexcept 가 아닌 이동 생성자에서는 vector 가 커질 때 복사가 1,048,575 번 일어나 43.32 밀리초. noexcept 를 붙이면 복사 0 번, 14.57 밀리초.
40 자 string 을 가진 원소를 100 만 번 push_back. 7 회 중앙값, -O2, Apple clang 21 / libc++, Apple Silicon Mac(M1 Pro).

noexcept 를 신경 쓰지 않고도 맞게 만드는 방법은 3강의 rule of zero 입니다. 특수 멤버 함수를 하나도 쓰지 않은 struct Plain { std::string s; }; 는 컴파일러가 이동 생성자를 만들어 주고, std::is_nothrow_move_constructible_v<Plain> 은 1 이었습니다. 문제는 특수 멤버 하나만 손으로 썼을 때 생깁니다. 연습 문제 2 가 가장 알아채기 어려운 예입니다.

흔한 실수 → 실제로 일어난 일 → 해결

소유권 버그 세 개를 한 파일에 넣고, 그냥 빌드와 -fsanitize=address,undefined -g 빌드로 실행했습니다.

struct Widget : std::enable_shared_from_this<Widget> {
    int id = 7;
    std::shared_ptr<Widget> self_wrong() { return std::shared_ptr<Widget>(this); }
    std::shared_ptr<Widget> self_right() { return shared_from_this(); }
};
// in main(), selected by argv[1]:
if (mode == 1) {                          // two owners of one raw pointer
    int *raw = new int(42);
    std::unique_ptr<int> a(raw);
    std::unique_ptr<int> b(raw);
} else if (mode == 2) {                   // raw pointer outlives its owner
    auto up = std::make_unique<Widget>();
    Widget *view = up.get();
    up.reset();
    std::printf("id=%d\n", view->id);
} else if (mode == 3) {                   // shared_ptr(this)
    auto sp = std::make_shared<Widget>();
    auto again = sp->self_wrong();
}
실수 그냥 빌드 ASan 빌드 해결
new 하나로 unique_ptr 두 개 Trace/BPT trap: 5, 종료 코드 133 attempting double-free, 19 행 make_unique 로 만들고 raw 포인터를 두 번 감싸지 않기
reset() 뒤에 get() 값 사용 id=0 출력, 종료 코드 0 heap-use-after-free, 23 행 소유자를 살려 두거나 shared_ptr + weak_ptr
메서드 안에서 shared_ptr<T>(this) Trace/BPT trap: 5, 종료 코드 133 bad-free (attempting free on address which was not malloc()-ed) enable_shared_from_this 상속 후 shared_from_this()
shared_ptr 양방향 연결 오류 없음, 소멸자 안 불림 조용함 역방향은 weak_ptr, leaks --atExit 로 확인
return std::move(local); 동작하지만 복사 생략을 막음 — return local; (-Wpessimizing-move)
소멸자만 선언, 이동 없음 확장할 때마다 복사 — rule of zero, 또는 다섯 개 모두 = default

가장 위험한 건 mode 2 입니다. 그냥 빌드는 그럴듯한 id=0 을 찍고 정상 종료했습니다. mode 3 은 "콜백에 자기 자신을 등록하는" 실무에서 흔한 모양인데, 같은 객체를 제어 블록 두 개가 소유하게 됩니다. shared_from_this() 로 바꾸면 둘 다 use_count=2, 즉 제어 블록이 하나였습니다.

어떤 소유 방식을 고를까

상황 쓸 것 이유
소유자가 분명히 하나(멤버, 팩토리 반환값) std::unique_ptr<T> 8 바이트, 카운트 없음, 이동이 싸다
소유자가 여럿이고 수명 끝을 알 수 없음(공유 캐시, 비동기 콜백) std::shared_ptr<T> 제어 블록의 카운트. 16 바이트, 카운트 갱신은 원자적
역방향 링크, 관찰자, 살려 두면 안 되는 캐시 std::weak_ptr<T> 소유하지 않음. 쓸 때 lock()
함수가 객체를 쓰기만 함 T& 또는 T* 매개변수 시그니처에 소유권을 끌어들이지 않음
객체 배열 std::vector<T> 이미 원소를 소유함

unique_ptr 로 시작하세요. 나중에 shared_ptr 로 바꿀 수 있지만(std::shared_ptr<T> s = std::move(u);) 반대는 안 됩니다. 그리고 읽기만 하는 함수에 스마트 포인터를 값으로 넘기지 마세요. void draw(const Shape&) 가 의도도 분명하고, 호출할 때마다 원자적 카운터를 건드리지도 않습니다.

솔직한 한계와 버전 메모

  • 실행 환경은 Apple clang 21.0.0(libc++, _LIBCPP_VERSION 220106), arm64 macOS 26 뿐입니다. GCC/libstdc++, MSVC, Linux, Valgrind 는 돌리지 않았습니다. libstdc++ 에서는 이동된 문자열이나 확장 비율이 다를 수 있습니다.
  • 버전: unique_ptr, shared_ptr, weak_ptr, 이동 의미론, noexcept 는 C++11, make_unique 는 C++14 입니다(-std=c++11 에서 거부되는 것 확인). 이 글의 코드는 -std=c++17, -std=c++20 으로도 컴파일됐습니다.
  • 확장 비율 2 배, 할당 크기(make_shared<Person> 하나에 80 바이트)는 libc++ 구현 사항이지 표준이 아닙니다.
  • 시간은 한 대에서 잰 중앙값입니다. 복사는 실행마다 16.5~17.0 ms, noexcept 비교는 별도 두 번 실행에서 43.3 ms 대 14.6 ms 였습니다.
  • Apple Silicon macOS 에서는 AddressSanitizer 의 누수 검출이 동작하지 않습니다(C 강좌 3강 참고). 그래서 leaks 를 썼습니다.

연습 문제

1. 각 Node 가 children 과 부모로 가는 parent 링크를 갖는 트리를 만드세요. 루트를 놓으면 전부 해제되도록 포인터 타입을 고르고, 소멸자 출력과 leaks --atExit 로 증명하세요.

2. 아래 타입은 std::vector 를 느리게 만듭니다. 이동에 대해서는 아무것도 쓰지 않았는데 왜일까요?

struct Logged {
    std::string text;
    ~Logged() {}            // added "just to set a breakpoint"
};

정답

1. 부모→자식은 소유, 자식→부모는 지켜보기만 합니다.

struct Node {
    std::string name;
    std::weak_ptr<Node> parent;                    // child -> parent: weak
    std::vector<std::shared_ptr<Node>> children;   // parent -> child: owning
    explicit Node(std::string n) : name(std::move(n)) {}
    ~Node() { std::printf("dtor %s\n", name.c_str()); }
};

루트, 자식 a, 손자 a1 을 만들고 손자만 바깥의 leaf 로 들고 있으면, a1's parent is a 다음 스코프가 끝나는 순간 dtor root, dtor a, 이어서 tree gone? parent expired=1, 마지막에 dtor a1 이 나왔습니다. leaks 는 0 leaks for 0 total leaked bytes. 였습니다. lock() 은 임시 shared_ptr 를 돌려주므로 if (auto p = ...) 안에서 쓰세요.

2. 소멸자를 직접 선언하면 암시적 이동 생성자가 만들어지지 않습니다. 복사는 되기 때문에 std::is_nothrow_move_constructible_v<Logged> 는 0 이었습니다(예외를 던질 수 있는 string 복사로 대신하므로). 용량이 1,024 에서 2,048 로 늘어나는 vector 에 뒤쪽 1,000 개를 넣는 동안 할당 횟수를 세면 Logged=3025, 소멸자를 지운 Clean=1001 입니다. 차이 2,024 번은 재배치 복사 1,024 번과, 넘긴 임시 객체 복사 1,000 번입니다. 빈 소멸자를 지우거나(rule of zero), Logged(Logged&&) noexcept = default; 등 다섯 개를 갖춰 쓰세요.

다음 강의

5강은 템플릿입니다. 114 줄짜리 템플릿 오류 읽는 법, C++20 콘셉트로 그것이 22 줄이 되는 모습, 람다와 STL 알고리즘, 지연 평가되는 ranges, 그리고 std::sort, std::stable_sort, qsort 실측입니다.

자주 묻는 질문

vector 가 커질 때 원소를 이동하지 않고 복사하는 이유는?

이동 생성자가 noexcept 가 아니기 때문입니다. vector 는 강한 예외 보장을 지키려고, 재배치 중 이동이 예외를 던질 수 있으면 복사를 택합니다. 실측에서 100 만 개 push_back 시 noexcept 가 없으면 복사 1,048,575 번·43.3 ms, 있으면 복사 0 번·14.6 ms 였습니다. 소멸자를 직접 선언하기만 해도 암시적 이동 생성자가 사라져 같은 일이 생깁니다.

shared_ptr 를 써도 메모리 누수가 생기나요?

생깁니다. 두 객체가 서로를 shared_ptr 로 들고 있으면 참조 카운트가 0 이 되지 않습니다. 예제에서는 소멸자가 한 번도 불리지 않았고, 스코프를 벗어나도 alive=2 그대로였으며, macOS 의 leaks 는 "2 leaks for 160 total leaked bytes" 와 ROOT CYCLE 을 보고했습니다. 한쪽 방향을 weak_ptr 로 바꾸자 두 소멸자가 모두 실행되고 leaks 도 0 이 됐습니다.

std::move 한 뒤 객체에는 무엇이 남나요?

'유효하지만 명시되지 않은 상태'입니다. libc++ 에서는 긴 문자열, 짧은 문자열, vector 모두 비어 있었고, 그 뒤 vector 에 push_back 해도 문제없었습니다. 그래도 내용에 기대지 말고 새 값을 대입하거나 소멸시키기만 하세요. const 객체에 std::move 를 쓰면 실제로는 복사되며 경고도 나오지 않습니다.

  • #C++
  • #중급
  • #스마트 포인터
  • #이동 의미론
  • #noexcept
  • #AddressSanitizer

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑