[Modern C++] 항목 25: rvalue 참조에는 std::move를, 보편 참조에는 std::forward를 사용하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 5. rvalue 참조, 이동 개념, 완벽 전달
👉🏻 항목 25: rvalue 참조에는 std::move를, 보편 참조에는 std::forward를 사용하라
🔍 기본 원칙
- rvalue 참조가 묶인 객체를 다른 함수로 넘길 때는 그 참조에
std::move를 적용해서 무조건 rvalue로 캐스팅한다. rvalue 참조는 항상 rvalue에 묶이기 때문이다. - 보편 참조가 묶인 객체를 다른 함수로 넘길 때는
std::forward를 적용해서 조건부 캐스팅한다. 보편 참조는 rvalue로 초기화될 때에만 rvalue에 묶이기 때문이다(항목 24 참고).
class Widget {
public:
Widget(Widget&& rhs) // rhs는 rvalue 참조
: name(std::move(rhs.name)),
p(std::move(rhs.p))
{ … }
…
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};
class Widget {
public:
template<typename T>
void setName(T&& newName) // newName은 보편 참조
{ name = std::forward<T>(newName); }
…
};
🔍 반대로 하면 안 되는 이유
- rvalue 참조에 std::forward: 원하는 동작은 가능하지만 소스 코드가 장황하고 실수의 여지가 있으며 관용구에서 벗어난다 → 피해야 함.
- 보편 참조에 std::move: 지역 lvalue(예: 지역 변수)가 의도치 않게 수정될 수 있다 → 훨씬 더 나쁘다.
class Widget {
public:
template<typename T>
void setName(T&& newName) // 보편 참조
{ name = std::move(newName); } // 컴파일되긴 하지만 아주 나쁘다!
…
};
std::string getWidgetName(); // 팩터리 함수
Widget w;
auto n = getWidgetName(); // n은 지역 변수
w.setName(n); // n을 w로 이동한다
// 이제 n의 값은 알 수 없다!
setName이 내부적으로std::move를 써서newName을 무조건 rvalue로 캐스팅해버리므로, lvalue인n을 넘겨도 그 내용이w.name으로 이동해 버리고n은 미지정 값이 된다.
🔍 “그냥 lvalue/rvalue 버전으로 오버로딩하면 되지 않나?”
class Widget {
public:
void setName(const std::string& newName) // const lvalue로
{ name = newName; } // name을 설정
void setName(std::string&& newName) // rvalue로
{ name = std::move(newName); } // name을 설정
…
};
- 동작은 하지만 단점이 있다.
- 소스 코드 증가: 템플릿 함수 하나가 비템플릿 함수 두 개로 늘어난다.
- 효율성 저하 가능성:
w.setName("Adela Novak")처럼 문자열 리터럴을 넘기면, 보편 참조 버전은 리터럴이 그대로std::string의 대입 연산자 인수로 쓰이지만, 오버로딩 버전은 임시std::string객체를 만들어(생성자 1회)newName에 묶고, 그것을 다시name으로 이동(이동 대입 1회)한 뒤 파괴(소멸자 1회)한다 — 추가 비용 발생. - 설계의 확장성(scalability) 문제: 매개변수가
n개이고 각각 lvalue/rvalue일 수 있다면 필요한 오버로딩 버전의 수가2ⁿ으로 기하급수적으로 증가한다.std::make_shared,std::make_unique처럼 임의 개수의 lvalue/rvalue 매개변수를 받는 함수 템플릿은 오버로딩이 사실상 불가능 — 보편 참조가 유일한 방안이다.
template<class T, class... Args> // C++11
shared_ptr<T> make_shared(Args&&... args);
template<class T, class... Args> // C++14
unique_ptr<T> make_unique(Args&&... args);
🔍 std::move/std::forward는 참조의 “마지막 사용” 지점에서만
- rvalue 참조나 보편 참조에 묶인 객체를 한 함수 안에서 여러 번 사용할 경우, 그 객체를 다 사용하기 전에 다른 곳으로 이동시켜서는 안 된다.
std::move나std::forward는 해당 참조의 마지막 사용 지점에서만 적용해야 한다.
template<typename T> // text는 보편 참조
void setSignText(T&& text)
{
sign.setText(text); // text를 사용하되 수정하지는 않는다
auto now = std::chrono::system_clock::now(); // 현재 시간을 얻는다
signHistory.add(now,
std::forward<T>(text)); // text를 rvalue로 조건부 캐스팅
} // (마지막 사용이므로 여기서 적용)
🔍 값 반환 함수에서의 std::move/std::forward — 이동 최적화
- 함수가 값으로(by value) 결과를 반환하고, 그 결과가 rvalue 참조나 보편 참조에 묶인 객체라면,
return문에서std::move/std::forward를 적용하는 것이 바람직하다.
Matrix // 결과를 값으로 반환
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return std::move(lhs); // lhs를 반환값으로 이동한다
}
template<typename T>
Fraction // 값 전달 방식의 반환값
reduceAndCopy(T&& frac) // 보편 참조 매개변수를 받는다
{
frac.reduce();
return std::forward<T>(frac); // rvalue는 반환값으로 이동하고,
} // lvalue는 복사한다
std::move/std::forward를 쓰지 않으면 해당 매개변수는 무조건 반환값 위치로 복사된다.Matrix/Fraction이 이동을 지원하지 않더라도 손해는 없다(rvalue가 그냥 복사 생성자로 복사될 뿐). 즉, 잃을 것은 없고 얻을 것은 있다.
⚠️ 함정 — 지역 변수 반환에는 절대 적용 금지
Widget makeWidget() // makeWidget의 '복사' 버전
{
Widget w;
…
return w; // w를 반환값으로 '복사'한다
}
- 이 코드는 조건 두 가지 — (1) 지역 객체의 타입이 함수의 반환 타입과 같고 (2) 그 지역 객체가 곧 함수의 반환값 — 를 모두 만족하므로, 컴파일러는 반환값 최적화(RVO)를 적용해 복사를 아예 제거할 수 있다.
Widget makeWidget() // makeWidget의 이동 버전 (이렇게 하면 안 됨!)
{
Widget w;
…
return std::move(w); // w를 반환값으로 이동한다
}
std::move(w)가 돌려주는 것은 지역 객체w가 아니라 그에 대한 참조다. 참조를 반환하는 것은 RVO의 필수 조건을 만족하지 못하므로, 컴파일러는 반드시w를 반환값 장소로 이동해야 한다 — 즉, 프로그래머가 컴파일러의 최적화 여지를 오히려 제한한 것이다.- 표준에 따르면, RVO가 허용되는 경우 컴파일러는 복사 제거를 하거나, 아니면 반환되는 객체에 암묵적으로
std::move를 적용해야 한다. 즉return w;는 이미 최적의 선택이다. - 값 전달 방식의 매개변수도 마찬가지다: 함수가 그런 매개변수를 반환하는 경우 컴파일러는 그것을 반드시 rvalue로 취급하므로, 여기에
std::move를 적용하는 것도 무의미하다.
🧐 정리
- rvalue 참조나 보편 참조가 마지막으로 쓰이는 지점에서, rvalue 참조에는
std::move를, 보편 참조에는std::forward를 적용하라. - 결과를 값 전달 방식으로 돌려주는 함수가 rvalue 참조나 보편 참조를 돌려줄 때에도 각각
std::move나std::forward를 적용하라. - 반환값 최적화(RVO)의 대상이 될 수 있는 지역 객체에는 절대로
std::move나std::forward를 적용하지 말아야 한다.
댓글남기기