[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::movestd::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::movestd::forward를 적용하라.
  • 반환값 최적화(RVO)의 대상이 될 수 있는 지역 객체에는 절대로 std::movestd::forward를 적용하지 말아야 한다.

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

댓글남기기