[EMC++] Item 25. rvalue reference에는 std::move를, universal reference에는 std::forward를 사용해라
Item 25. rvalue reference에는 std::move를, universal reference에는 std::forward를 사용해라
rvalue reference에는 std::move 사용
rvalue reference는 이동할 수 있는 객체를 참조한다.
rvalue reference를 다른 함수로 전달할 때, 항상 rvalue로 캐스팅해야 하므로 std::move
를 사용해야 한다.
class Widget {
public:
Widget(Widget&& rhs)
: name(std::move(rhs.name)),
p(std::move(rhs.p)) {}
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};
rvalue reference에 std::forward 사용 시 단점
rvalue reference에 std::forward
를 사용해도 되지만 소스 코드가 장황하고 실수의 여지가 있으며 관용구에서 벗어난 모습이 된다는 단점이 있다.
class Widget {
public:
Widget(Widget&& rhs)
: name(std::forward<std::string>(rhs.name)),
p(std::forward<std::shared_ptr<SomeDataStructure>>(rhs.p)) {}
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};
보는 것처럼 std::forward
는 타입을 명시해줘야하며 이동 연산을 위해서 사용한다는 목적이 드러나지 않는다. (전달이 목적이기 때문에)
universal reference에는 std::forward 사용
universal reference는 이동할 수 있는 객체를 참조할 수도 아닐 수도 있다.
universal reference를 다른 함수로 전달할 때, 참조하는 객체가 rvalue일 때만 rvalue로 캐스팅해야 하므로 std::forward
를 사용해야 한다.
class Widget {
public:
template <typename T>
void setName(T&& newName) {
name = std::forward<T>(newName);
}
private:
std::string name;
};
universal reference에 std::move 사용 시 단점
만약 universal reference에서 std::forward
대신 std::move
를 사용하면 다음과 같은 문제가 발생할 수 있다.
class Widget {
public:
template <typename T>
void setName(T&& newName) {
name = std::move(newName);
}
private:
std::string name;
};
// std::string 타입을 반환하는 팩토리 함수
std::string getWidgetName(void);
Widget w;
auto n = getWidgetName();
w.setName(n); // w 멤버 함수 내부에서 n이 이동됨
// 이제 n의 값을 알 수 없음
위 예시에서 알 수 있듯이 std::move
는 참조하는 객체가 lvalue인지 rvalue인지 상관없이 rvalue reference로 바꿔버려 의도치 않은 이동 연산이 발생할 수 있다.
이 문제를 해결하기 위해 setName
함수를 rvalue reference 버전과 rvalue reference 버전으로 나눈 overloading 함수로 구현하는 방법이 있다.
class Widget {
public:
// lvalue reference 버전
void setName(const std::string& newName) {
name = newName;
}
// rvalue reference 버전
void setName(std::string&& newName) {
name = std::move(newName);
}
private:
std::string name;
};
하지만 이런 방식의 해결법은 문제점이 있다.
작성하고 유지보수해야 할 소스 코드의 양이 늘어나서 효율성이 떨어질 수 있다는 점이다.
w.setName("Adela Novak");
만약 다음과 같이 문자열 리터럴을 인자로 넘겨줄 경우, 템플릿 함수는 문자열 리터럴의 타입을 const char*
로 추론해서 name
멤버 변수 초기화에 바로 사용할 수 있다.
하지만 overloading 함수를 사용하면 문자열 리터럴로부터 임시 std::string
객체를 생성한 후 name
멤버 변수로 이동하고 파괴하는 과정을 거쳐야 한다.
즉, 앞서 말한 것처럼 효율성이 떨어진다.
lvalue와 rvalue에 대한 overloading 함수 구현의 가장 심각한 문제는 설계의 확장성(scalability)이 나쁘다는 것이다.
Widget::setName
함수는 매개변수를 하나만 받으므로 overloading 함수가 두 개면 되지만 매개변수가 더 많고 각 매개변수가 lvalue일 수도 있고 rvalue일 수도 있다면 overloading 함수의 수가 기하급수적으로 증가한다.
나아가 템플릿 함수 중에는 lvalue일 수도 있고 rvalue일 수도 있는 매개변수를 무제한으로 받을 수 있는 함수들이 존재한다.
// C++11 표준에서 발췌
template <class T, class... Args>
shared_ptr<T> make_shared(Args&&... args);
// C++14 표준에서 발췌
template <class T, class... Args>
unique_ptr<T> make_unique(Args&&... args);
이런 함수들에 대해서는 lvalue와 rvalue에 대한 overloading 함수 구현이 현실적으로 불가능하다.
따라서 universal reference를 사용할 때는 std::forward
를 사용해야 한다.
std::move, std::forward는 마지막 참조에 사용해야 됨
rvalue reference와 universal reference는 참조하는 객체를 여러 번 사용할 수도 있는데 std::move
와 std::forward
는 마지막 참조에 사용해야 한다.
template <typename T>
void setSignText(T&& text) {
// 여기서 text가 이동해버리면 아래에서 문제가 발생할 수 있음
sign.setText(text);
auto now = std::chrono::system_clock::now();
// text를 마지막으로 참조하는 라인이라 이동이 발생해도 문제 없음
signHistory.add(now, std::forward<T>(text));
}
std::forward
를 마지막 참조에 사용해야하는 이유는 간단하다.
아래 라인에서도 해당 객체를 사용해야하는데 객체가 이동해서 값이 변경되면 문제가 발생할 수 있기 때문이다.
std::move
에 대해서도 같은 논리가 적용된다.
값 반환 방식에서 rvalue reference나 universal reference를 돌려줄 때에도 std::move나 std::forward를 사용해야 함
값 반환 방식(Return by Value)에서 rvalue reference나 universal reference를 돌려줄 때에도 std::move
나 std::forward
를 사용해야 한다.
이유는 당연히 복사 연산보다 이동 연산이 성능이 더 좋기 때문이다.
Matrix operator+(Matrix&& lhs, const Matrix& rhs) {
lhs += rhs;
return lhs; // lhs를 반환값으로 복사
}
// Matrix가 이동을 지원하지 않으면 복사가 일어남
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); // lvalue면 복사, rvalue면 이동
}
만약 리턴 타입이 이동 연산을 지원하지 않더라도 문제없다.
이동 연산을 지원하지 않으면 복사 연산이 일어날 것이고 나중에라도 이동 연산이 구현되면 사용할 수 있기 때문이다.
RVO의 대상이 될 수 있는 지역 객체에는 절대로 std::move나 std::forward를 적용하지 말아야 함
RVO의 대상이 될 수 있는 지역 객체에는 절대로 std::move
나 std::forward
를 적용하지 말아야 한다.
먼저 RVO에 대해 알아보자.
RVO(Return Value Optimization)
RVO(Return Value Optimization)는 컴파일러에 의해 최적화되는 반환 값이다.
RVO는 처음부터 반환받을 메모리 공간에 값을 할당해서 불필요한 복사를 방지한다. (복사 생략(copy elision)이라 부른다.)
RVO의 조건은 다음과 같다.
- 지역 객체의 타입이 함수의 반환 타입과 같아야 함
- 지역 객체가 바로 함수의 반환 값이어야 함
여기서 지역 객체는 지역 변수와
return
문으로 생성되는 임시 객체를 모두 포함한다. (함수의 매개변수는 포함하지 않는다.)
std::move나 std::forward를 사용하면 RVO가 불가능한 이유
만약 RVO의 대상이 될 수 있는 지역 객체를 std::move
나 std::forward
를 적용하여 반환하면, 2번 조건을 불만족하여 RVO의 혜택을 받을 수 없다.
std::move
나 std::forward
를 적용한 후에는 지역 객체 자체가 아니라 지역 객체의 참조자를 반환하게 되기 때문이다.
// RVO 가능
Widget makeWidget(void) {
Widget w;
return w; // 복사가 아닌 복사 생략이 발생
}
// RVO 불가능
Widget makeWidget(void) {
Widget w;
return std::move(w); // 이동 발생
}
함수의 매개변수 타입과 값 반환 타입이 같으면 컴파일러는 다음과 같이 처리한다.
// 실제로 작성된 코드
Widget makeWidget(Widget w) {
return w;
}
// 컴파일러가 처리하는 코드
Widget makeWidget(Widget w) {
return std::move(w); // w를 rvalue로 취급
}
정리하면 지역 객체의 값을 반환하는 경우, 지역 객체에 std::move
를 적용한다고 해서 성능이 좋아지는 것이 아니라 오히려 RVO에 방해가 될 수 있다.