[Modern C++] 항목 25: rvalue 참조에는 std::move를, 보편 참조에는 std::forward를 사용하라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 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을 설정
  …
};
  • 동작은 하지만 단점이 있다.
    1. 소스 코드 증가: 템플릿 함수 하나가 비템플릿 함수 두 개로 늘어난다.
    2. 효율성 저하 가능성: w.setName("Adela Novak")처럼 문자열 리터럴을 넘기면, 보편 참조 버전은 리터럴이 그대로 std::string의 대입 연산자 인수로 쓰이지만, 오버로딩 버전은 임시 std::string 객체를 만들어(생성자 1회) newName에 묶고, 그것을 다시 name으로 이동(이동 대입 1회)한 뒤 파괴(소멸자 1회)한다. 추가 비용이 발생한다.
    3. 설계의 확장성(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를 적용하지 말아야 한다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기