C++에서 복사가 언제 일어나는지 헷갈릴 때 쓰는 간단한 요령이 있다. lvalue니 rvalue니 하는 규칙을 외우는 대신, 어떤 데이터를 가리키는 살아 있는 이름이 몇 개인지 센다. 이걸 이름 세기(name counting)라 부른다.
규칙은 한 줄이다. 같은 데이터를 가리키는 이름이 두 개 살아 있는 순간에는, 그 데이터의 복사본도 두 개 존재한다. 그 외의 경우엔 컴파일러가 복사를 없앤다. 여기서 “이름”은 포인터도 참조도 아닌 값 타입 변수(엄밀히는 lvalue)를 말한다.
값 타입 — 이름을 세면 복사가 보인다
복사·이동 생성자가 불릴 때마다 세는 타입으로 세 가지 초기화를 재봤다.
struct Tracked {
Tracked() {}
Tracked(const Tracked&) { ++copies; }
Tracked(Tracked&&) noexcept { ++moves; }
static int copies, moves;
};
Tracked build() { return Tracked{}; } // 임시를 반환| 코드 | 살아 있는 이름 | 복사 | 이동 |
|---|---|---|---|
Tracked a = build(); | 1개 (임시) | 0 | 0 |
Tracked b = src; | 2개 (src, b) | 1 | 0 |
Tracked c = std::move(src); | 이름 지움 | 0 | 1 |
첫 줄은 이름이 하나뿐이다 — 임시가 곧 a가 되므로 복사가 생략된다(왜 생략되는지는 「C++에서 복사는 언제 사라지는가」에서 다뤘다). 둘째 줄은 src와 b, 두 이름이 같은 데이터를 가리키니 복사가 한 번 일어난다. 셋째 줄은 std::move(src)로 “src를 더는 이름으로 세지 않겠다”고 선언한 것이라, 복사 없이 이동만 남는다.
참조는 이름을 늘리지 않는다.
Tracked local = build(); // 이름 1개
Tracked& ref = local; // 참조 — 복사 아님, 이름도 안 늘어남그래서 복사가 걱정될 때는 “이 데이터를 가리키는 이름이 지금 몇 개인가”만 보면 된다. 이름을 굳이 하나 더 만들지 않으면 컴파일러가 복사를 없앤다. C++17에서 복사 생략이 의무가 된 뒤로 이 규칙은 하한이 아니라 사실상 보장에 가깝다 — 벤치마크에서 복사가 더 잡히면 컴파일러 버그를 의심할 정도다.
move-only 타입 — 이름이 하나만 허용된다
std::unique_ptr은 이름 세기가 규칙이 아니라 강제가 되는 자리다. 하나의 포인터 값은 어느 시점에도 단 하나의 unique_ptr에만 있어야 한다 — 즉 이름이 오직 하나여야 한다. 이름을 하나 더 만들려 하면 복사가 필요한데, unique_ptr은 복사 생성자가 delete돼 있어 컴파일이 안 된다.
std::unique_ptr<Foo> NewFoo() { return std::make_unique<Foo>(); }
void AcceptFoo(std::unique_ptr<Foo> f) {}
void ok() { AcceptFoo(NewFoo()); } // 이름 1개 — OK
void bad() { auto g = NewFoo(); AcceptFoo(g); } // 이름 2개(g, f) — 컴파일 에러bad()를 컴파일하면 g++가 이렇게 막는다.
error: use of deleted function
'std::unique_ptr<...>::unique_ptr(const std::unique_ptr<...>&)'
void bad() { auto g = NewFoo(); AcceptFoo(g); }
~~~~~~~~~^~~
NewFoo()가 만든 포인터를 가리키는 이름이 g(호출부)와 f(AcceptFoo 매개변수) 둘이 되는 순간, 복사가 필요해지고 삭제된 복사 생성자에 걸린다. 값 타입에서는 조용히 복사 한 번으로 넘어갔을 일이, move-only에서는 컴파일 에러로 드러난다.
고치는 법도 이름 세기로 나온다 — 이름을 지우면 된다. std::move()가 그 이름 지우개다.
void fixed() { auto h = NewFoo(); AcceptFoo(std::move(h)); } // move로 이름을 지움 — OKstd::move(h)는 “h를 더는 이 포인터의 이름으로 세지 않겠다”는 약속이다. 그러면 이름이 다시 하나(AcceptFoo의 f)로 줄어 규칙을 통과한다. 대신 그 약속대로, 이동 뒤의 h는 다시 대입하기 전까지 읽지 않아야 한다.
정리
- 복사가 궁금하면 이름을 센다. 같은 데이터를 가리키는 살아 있는 이름이 둘이면 복사가 한 번 있고, 하나 이하면 복사는 없다(임시거나
std::move로 지웠거나). - 이름이 너무 많으면
std::move로 지운다.std::move는 이동을 실행하는 게 아니라 “이 이름을 더는 세지 않겠다”는 표시일 뿐이다. - 값 타입은 복사로, move-only는 컴파일 에러로 드러난다.
unique_ptr이 컴파일 에러를 내면 대개 이름을 하나 더 만든 것이니, 그 자리에std::move를 넣으면 된다. - 함수에 값을 넘길 때 이 이름 세기가 어떻게 이득/손해로 갈리는지는 「C++ 매개변수를 값으로 받고 옮기기」에서 이어진다.
참고
- Abseil Tip of the Week #24 — Copies, Abbrv. — “살아 있는 이름 두 개 = 복사본 두 개” 규칙을 제시한 글(Titus Winters, Chandler Carruth)
- Abseil Tip of the Week #55 — Name Counting and unique_ptr — 이름 세기를
unique_ptr에 적용하고std::move를 “이름 지우개”로 설명 - cppreference — std::unique_ptr — 복사 생성자가
delete돼 있고 이동만 되는 것(move-only)의 근거