문자열 하나를 멤버로 저장하는 생성자를 어떻게 쓰겠는가. 오래된 답은 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냐로 뒤집힌다.
참고
- Abseil Tip of the Week #117 — Copy Elision and Pass-by-value — 값 전달 +
std::move기법과 그 단점(대입 시 버퍼 재사용 상실)을 짚은 글. 이 글은 그 단점을 실제 할당 횟수(0 대 1000)로 측정해 확인했다 - cppreference — Copy elision — 값 매개변수 초기화 시 함수 경계에서 복사가 생략되는 근거