KoreanFoodie's Study

Effective Modern C++ | 항목 25 : 오른값 참조에는 std::move 를, 보편 참조에는 std::forward 를 사용하라 본문

Tutorials/C++ : Advanced

Effective Modern C++ | 항목 25 : 오른값 참조에는 std::move 를, 보편 참조에는 std::forward 를 사용하라

GoldGiver 2022. 10. 26. 09:59

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 25 : 오른값 참조에는 std::move 를, 보편 참조에는 std::forward 를 사용하라

핵심 :

1. 오른값 참조나 보편 참조가 마지막으로 쓰이는 지점에서, 오른값 참조에는 std::move 를, 보편 참조에는 std::forward 를 적용하라.
2. 결과를 값 전달 방식으로 돌려주는 함수가 오른값 참조나 보편 참조를 돌려줄 때에도 각각 std::move 나 std::forward 를 적용하라.
3. 반환값 최적화의 대상이 될 수 있는 지역 객체에는 절대로 std::move 나 std::forward 를 적용하지 말아야 한다. 

 

요약하자면, 오른값 참조가 필요한 상황에서는 std::move 를 사용하며, std::forward 를 사용하는 것은 피해야 한다. 마찬가지로 보편 참조에 std::move 를 사용해서도 안된다. 왼값이 의도치 않게 수정될 수 있기 때문이다. 예시를 보자.

class Widget {
public:
  template<typename T>
  void setName(T&& newName)
  { name = std::move(newName); }
private:
  std::string name;
  std::shared_ptr<SomeDataStructure> p;
};

// 팩터리 함수
std::string getWidgetName();

Widget w;
auto n = getWidgetName();

// n 을 w 로 이동
w.setName(n);

// 이제 n 의 값은 알 수 없다! (std::move 됨)
...

물론 왼값과 오른값에 대한 오버로딩 함수를 따로 만드는(const std::string& 을 받는 녀석과 std::string&& 을 받는 녀석 두 가지를 생성) 방법도 있지만, 코드가 길어지고 비효율적이며, 임시 객체를 생성할 수도 있어 추가 비용을 걱정해야 할 수도 있다.

또한, 그런 식으로 매개변수의 왼/오른값 속성에 따라 오버로딩 함수를 만들면, 인자 하나당 2배의 갯수가 필요하므로, 설계 상으로도 매우 좋지 않다. 반면, 보편 참조를 응용한 템플릿은 다음과 같이 매개변수를 무제한으로 받을 수 있다. 예시를 보자.

// 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);

이런 경우에서는 왼값과 오른값에 대한 오버로딩 함수를 만들 수 없다. 유일한 방안은 보편 참조이다!

 

경우에 따라서, 오른값 참조나 보편 참조에 묶인 객체를 한 함수 안에서 여러 번 사용하기도 한다. 그런 경우, 그 객체를 다 사용하기 전에 다른 객체로 이동하는 일은 피해야 한다. 즉, 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));
}

위에서, setText 는 text 값을 수정하면 안 될 것이다!

 

함수가 결과를 값으로 돌려준다면, 그리고 그것이 오른값 참조나 보편 참조에 묶인 객체라면, 해당 참조를 돌려주는 return 문에서 std::move 나 std::forward 를 사용하는 것이 바람직하다. 예시를 보자.

Matrix
operator+(Matrix&& lhs, const Matrix& rhs)
{
  lhs += rhs;
  return std::move(lhs);
}

return 문에서 lhs 를 오른값으로 캐스팅한 덕분에, 컴파일러는 lhs 를 함수의 반환값 장소로 이동한다. 다음처럼 std::move 호출이 없다면, lhs 가 왼값이므로, 컴파일러는 그것을 반환값 장소로 복사해야 한다. Matrix 에서 이동 생성이 복사 생성보다 효율적일 경우, 코드가 더 효율적으로 동작할 것이다. 만약 이동 생성이 정의되어 있지 않더라도, 그냥 복사 생성이 이루어지므로 딱히 손해볼 일은 없다!

아래의 std::forward 의 경우도 마찬가지이다.

template<typename T>
Fraction
reduceAndCopy(T&& frac)
{
  frac.reduce();
  // 오른값은 이동, 왼값은 복사
  return std::forward<T>(frac);
}

 

그런데 일부 프로그래머들은 "함수가 반환할 지역 변수에도 위와 같은 최적화를 적용할 수 있을 것이다" 라는 엉뚱한 결론을 내린다. 예를 보자.

// '복사' 버전
Widget makeWidget()
{
  Widget w;

  ... // Do Something

  return w;
}


// '이동' 버전
Widget makeWidget()
{
  Widget w;

  ... // Do Something

  return std::move(w);
}

표준 위원회는 '복사' 버전에서, 만일 지역 변수 w 를 함수의 반환값을 위해 마련한 메모리 안에 생성한다면 w 의 복사를 피할 수 있다는 점을 이미 알고 있었다. 이것이 소위 반환값 최적화(return value optimization, RVO) 이다. 표준 위원회는 이러한 최적화를 명시적으로 승인했다.

이러한 복사 제거(copy elision) 은 (1) 그 지역 객체의 형식이 함수의 반환 형식과 같아야 하고, (2) 그 지역 객체가 바로 함수의 반환값이어야 한다. 위에서 살펴본 makeWidget 의 복사 버전은 이 두 가지 조건을 만족한다.

반면 이동 버전은 w 에 대한 참조(std::move(w) 의 결과)를 돌려주므로, 반환값 최적화의 필수 조건을 만족하지 못한다. 따라서 컴파일러의 최적화 여지를 제한해 버린 것이다!

또한 반환값 최적화의 필수 조건이 성립했지만 컴파일러가 복사 제거를 수행하지 않기로 한 경우, 반환되는 객체는 반드시 오른값으로 취급된다. 따라서, 최적화가 허용되면 복사 제거가 일어나고, 아니면 암묵적으로 std::move 가 일어난다는 것이다!

 

Comments