문자열 하나를 멤버로 저장하는 생성자를 어떻게 쓰겠는가. 오래된 답은 const&다.

struct Widget {
  std::string name_;
  explicit Widget(const std::string& name) : name_(name) {}   // ① const 참조
};

그런데 요즘 점점 자주 보이는 다른 형태가 있다.

struct Widget {
  std::string name_;
  explicit Widget(std::string name) : name_(std::move(name)) {}  // ② 값 + move
};

std::string을 값으로 받으면 복사라 비싸 보인다. 그런데 임시 객체를 넘기면 ②가 ①보다 싸다는 게 이 기법의 주장이다. 정말 그런지, 그리고 어디서 반대로 손해가 나는지 g++ 13.2로 세어봤다.

생성할 때 — 복사와 이동을 세어본다

복사·이동 생성자가 불릴 때마다 세는 타입으로 두 생성자를 각각 임시 인자와 lvalue 인자로 호출했다.

struct Tracked {
  Tracked() {}
  Tracked(const Tracked&) { ++copies; }        // 복사 생성자
  Tracked(Tracked&&) noexcept { ++moves; }     // 이동 생성자
  static int copies, moves;
  static void reset() { copies = moves = 0; }
};
int Tracked::copies = 0, Tracked::moves = 0;
Tracked make() { return Tracked{}; }   // 임시 반환
인자const&값 + move
임시(rvalue)복사 1이동 1 (복사 0)
lvalue복사 1복사 1 + 이동 1

이 표는 다음 측정 코드를 돌려 나온 것이다. 두 생성자를 각각 임시·lvalue로 호출하고, 호출마다 카운터를 리셋해 복사·이동 횟수를 찍었다.

struct WidgetRef { Tracked t_; explicit WidgetRef(const Tracked& t) : t_(t) {} };         // ① const&
struct WidgetVal { Tracked t_; explicit WidgetVal(Tracked t) : t_(std::move(t)) {} };     // ② 값 + move
 
int main() {
  Tracked::reset(); WidgetRef a(make());  printf("const&,  임시  : copy=%d move=%d\n", Tracked::copies, Tracked::moves);
  Tracked::reset(); WidgetVal b(make());  printf("값+move, 임시  : copy=%d move=%d\n", Tracked::copies, Tracked::moves);
  Tracked lv;
  Tracked::reset(); WidgetRef c(lv);      printf("const&,  lvalue: copy=%d move=%d\n", Tracked::copies, Tracked::moves);
  Tracked::reset(); WidgetVal d(lv);      printf("값+move, lvalue: copy=%d move=%d\n", Tracked::copies, Tracked::moves);
}
const&,  임시  : copy=1 move=0
값+move, 임시  : copy=0 move=1
const&,  lvalue: copy=1 move=0
값+move, lvalue: copy=1 move=1

임시를 넘길 때 ②가 이긴다. ①은 임시를 const&로 받아 멤버에 복사한다(복사 1). ②는 임시가 값 매개변수를 초기화하는데, 이때 **복사가 함수 경계에서 생략(copy elision)**되어 임시가 곧 매개변수가 되고, 그걸 멤버로 이동한다(이동 1). 복사가 사라지고 O(1) 이동만 남는다. 경계에서 복사가 왜 사라지는지는 「C++에서 복사는 언제 사라지는가」에서 다뤘으니 여기서는 결과만 쓴다.

lvalue를 넘길 때는 ②가 이동 하나만큼 더 든다. 둘 다 복사는 한 번인데(이름 있는 변수라 생략 못 함), ②는 매개변수에서 멤버로 이동을 한 번 더 한다. 이동은 O(1)이라 대개 감수할 만하다.

여기까지만 보면 “②가 임시엔 이득, lvalue엔 미미한 손해”라 나쁘지 않다. 그런데 대입은 이야기가 다르다.

대입할 때 — 값 전달이 버퍼 재사용을 잃는다

set_name 같은 세터를 생각해보자. const&name_ = n으로 기존 버퍼를 재사용할 수 있다 — name_에 이미 확보된 용량에 새 값이 들어가면 새로 할당하지 않는다. 값 + move는 매번 매개변수를 멤버로 통째 교체하므로 그 재사용 기회를 잃는다. operator new를 가로채 100자 문자열을 1000번 대입하며 할당 횟수를 셌다.

struct WRef { std::string name_; void set(const std::string& n) { name_ = n; } };
struct WVal { std::string name_; void set(std::string n) { name_ = std::move(n); } };
대입 1000회const&값 + move
lvalue 인자할당 0할당 1000
임시 인자할당 1000할당 1000

lvalue를 반복 대입하면 ①은 할당이 0이고 ②는 1000이다. ①은 매번 name_의 기존 100자 버퍼를 재사용한다. ②는 lvalue를 값 매개변수로 복사하느라(생략 불가) 매번 새로 할당하고, 그걸 멤버로 이동한다. 생성에서 미미했던 차이가 대입에서는 할당 1000번으로 벌어진다.

임시를 대입할 때는 둘 다 1000이다 — 임시 자체를 만드는 할당이 매번 들어가기 때문이다. 즉 값 전달의 손해는 “lvalue를 반복 대입”하는 자리에서 터진다. name_이 시간이 지나며 커지는 필드라면 이 차이는 더 벌어진다.

정리

값 + move는 만능이 아니라 자리를 탄다.

상황유리한 쪽이유
임시로 생성② 값+move경계에서 복사 생략 → 이동만
lvalue로 생성비등(①이 이동 하나 덜)둘 다 복사 1, ②만 이동 추가
lvalue로 반복 대입① const&기존 버퍼 재사용(할당 0)

실천으로 옮기면 이렇다.

  • 기본은 const&다. 더 단순하고, 안전하고(이동된 값 접근 위험이 없다), 대입에서 버퍼를 재사용한다.
  • 값 + move는 “복사가 반드시 필요한 매개변수”이고 “임시로 넘어오는 일이 잦은” 생성 경로에서만 쓴다. 복사가 필요 없거나 조건부인 매개변수에 붙이면 이득이 없거나 오히려 해롭다.
  • 세터(대입)에는 값 전달을 붙이지 않는다. lvalue를 자주 받으면 버퍼 재사용을 잃어 할당이 폭증한다.
  • 확신이 안 서면 재본다. 이득의 방향이 생성이냐 대입이냐, 임시냐 lvalue냐로 뒤집힌다.

참고