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개 (임시)00
Tracked b = src;2개 (src, b)10
Tracked c = std::move(src);이름 지움01

첫 줄은 이름이 하나뿐이다 — 임시가 곧 a가 되므로 복사가 생략된다(왜 생략되는지는 「C++에서 복사는 언제 사라지는가」에서 다뤘다). 둘째 줄은 srcb, 두 이름이 같은 데이터를 가리키니 복사가 한 번 일어난다. 셋째 줄은 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로 이름을 지움 — OK

std::move(h)는 “h를 더는 이 포인터의 이름으로 세지 않겠다”는 약속이다. 그러면 이름이 다시 하나(AcceptFoof)로 줄어 규칙을 통과한다. 대신 그 약속대로, 이동 뒤의 h는 다시 대입하기 전까지 읽지 않아야 한다.

정리

  • 복사가 궁금하면 이름을 센다. 같은 데이터를 가리키는 살아 있는 이름이 둘이면 복사가 한 번 있고, 하나 이하면 복사는 없다(임시거나 std::move로 지웠거나).
  • 이름이 너무 많으면 std::move로 지운다. std::move는 이동을 실행하는 게 아니라 “이 이름을 더는 세지 않겠다”는 표시일 뿐이다.
  • 값 타입은 복사로, move-only는 컴파일 에러로 드러난다. unique_ptr이 컴파일 에러를 내면 대개 이름을 하나 더 만든 것이니, 그 자리에 std::move를 넣으면 된다.
  • 함수에 값을 넘길 때 이 이름 세기가 어떻게 이득/손해로 갈리는지는 「C++ 매개변수를 값으로 받고 옮기기」에서 이어진다.

참고