본문 바로가기
컴퓨터/Modern Effective C++ 정리

Modern Effective C++ item 25 : std::move / std::forward 를 적재적소에 사용하자.

by 김짱쌤 2015. 4. 28.

std::move / std::forward 를 적재적소에 사용하자.

item 23,24에서 이야기한 것처럼, std::move는 인자를 무조건적으로 rvalue reference로 캐스팅하고, std::forward는 인자가 rvalue인지 체크해서 조건부로 rvalue reference로 변경한다. 여기서 주의해야할 사항이 있다. universial Reference에는 std::move를 사용해선 안되고, rvalue reference에는 std::forward를 사용해선 안된다. 전자는 정말 위험하다.

universial Reference에 std::move를 쓰지말자.

class Widget{
public:
   template<typename T>
   void setName(T&& newName)      //T&&는 universial reference
   { name = std::move(newName); } //move로 넘긴다.
                                  //컴파일은 되나 Baaaaaaad!!

private:
   std::string name;
   std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName();  //factory함수
...
Widget w;
auto n = getWidgetName(); 
w.setName(n);   //인자로 lvalue가 전달되었다.
                //하지만 내부적으로 std::move하니 n의 내용은 지워진다.
...
n.DoSomthing(); //error!

위 코드를 보면 이해할 수 있지만, universial reference에는 lvalue의 reference도 전달 가능하다. 따라서 기존에 사용하던 lvalue가 전달될 수 있다. 그런데 setName에서 전달받은 universial reference를 std::move를 사용하여 강제 rvalue reference화 하고 있다. 그러면 rvalue reference를 받은 string class는 읽기전용 rvalue라고 간주하여 move 연산을 수행해버린다. 그러면 기존에 사용하던 n의 정보는 모두 날라간다. 이런 위험을 방지하기 위해서 universial reference와 std::move를 같이 쓰면 안된다. 대신 lvalue와 rvalue를 분명히 파악할 수 있는 환경에서 각각에 대한 함수를 오버로딩하여 만들어 주는 방법을 생각해 볼 수 있다.

class Widget{
public:
   //lvalue는 복사생성자가 호출되도록
   void setName(const std::string& newName)
   { name = newName; }

   //rvalue는 move생성자가 호출되도록
   void setName(std::string&& newName)
   { name = std::move(newName); }
}

과연 오버로딩 이대로 좋은가?

위 코드는 제대로 동작한다. 하지만 아쉬운 점이 두개나(!) 있다. 첫번째는 소스코드를 많이 써야하고, 유지보수(두개의 함수 각각)에 힘이 좀더 들어간다. 두번째는 이 함수의 동작이 비효율 적인 경우가 있다는 것이다.

w.setName("Adela Novak");

위 코드가 바로 그 비효율적인 동작이 발생하는 케이스이다. 템플릿을 사용하는 기존 universial 버전의 코드에서는 "Adela Novak"이라는 const char* 자체가 그대로 레퍼런싱되어서 string의 operator=에게 전달된다. 그러면 string의 생성자중 const char를 받는 대입연산자가 바로 대입 연산을 수행한다. 하지만 오버로딩한 함수의 경우 패러미터로 char가 아닌 string을 받으므로, 인자전달을 위하여 임시적으로 사용되는 string이 char*를 통해 생성된다. 그리고 move연산을 거쳐 임시생성된 string의 소멸자까지 처리해야한다. 이건 아무리 좋게봐줘도 쓸데없이 발생하는 비용이다.

하지만 진짜 문제는 이런 추가적 비용이 아니다. setName함수가 오직 정해진 string 타입만을 패러미터로 받는다는 점이다. 추가적인 비용은 이 문제에 수반되는 문제일 뿐이다. 만약 setName에 필요한 인자가 늘어난다면, lvalue 버전 rvalue 버전 각각에 해당하는 함수를 만들어줘야할것이다. 그러면 오버로딩해아하는 함수의 개수가 늘어난 인자의 2^n 수준으로 증가한다. 이것이 끝이 아니다. template 타입에는 varadic template 타입이라는 것이 있다. 실지로 varadic template을 사용하는 make_shared나 make_unique를 생각해보자.

template<class T, class... Args>
shared_ptr<T> make_shared(Args&&... args);

template<class T, class... Args>
unique_ptr<T> make_unique(Args&&... args);

이런 함수를 lvalue/rvalue별로 오버로딩하는 것은 불가능하다. 이젠 정말 universial reference 뿐이야. 이럴 때는 scott meyers님이 강하게 보장하는 std::forward를 쓰자. 너무 강하게 이야기 해서 미안하지만, universial reference 인자에 무조건 std::forward를 쓰는건 또 문제라고 한다.(후...) 한 함수에서 받은 인자를 여러번 사용해야하는 경우가 바로 그거다. 이 부분에 한해서는 rvalue reference건 universial reference건 동일하게 적용되니 주의깊게 보자.

template<typename T>
void setSignText(T&& text)//universial reference로 인자를 받았다.
{
   sign.setText(text);    //여기서 std::forward를 쓰면 아래에서 쓸수가 없다.

   auto now = std::chrono::system_clock::now();
   //더 이상 같은 변수를 쓸 일이 없다고 생각될 때 std::forward(or move)를 사용하자.
   signHistory.add(now, std::forward<T>(text));
};

std::move나 std::forward를 써버리면 다음에 또 사용해야되는 변수의 내용이 손상될 수 있다. 그러니까 꼭 std::forward(move)는 해당 변수를 마지막으로 사용할 때 쓰자.

받은 인자를 값으로 리턴해야되는 경우

인자를 rvalue reference로 받았고 리턴은 value타입으로 해야되는 경우, 우리는 리턴값을 따로 만들어서 반환해야한다. 이때 std::move 또는 std::forward를 사용하는 것이 이득이다. 행렬 Matrix의 덧셈 함수 operator+를 생각해보자.

//rvalue reference를 사용하는 operator+
Matrix operator+(Matrix&& lhs, Matrix&& rhs)
{
   lhs += rhs;
#ifdef _CASE1
   return lhs;  
#elseif _CASE2
   return std::move(lhs);
#endif
}

rvalue reference역시 lvalue라는 것을 상기해보자. CASE1처럼 lhs를 그대로 반환하면 Matrix의 복사 생성자가 호출되어 리턴 값을 생성한뒤 반환할 것이다. 하지만 Matrix에 훨씬 효율적인 move 생성자가 있다면 어떨까? 기껏 rvalue reference를 받았는데 아깝지 않은가? 그래서 우리는 std::move를 사용하는 CASE2를 떠올리게 된다. move 생성자가 있다면 CASE2가 훨씬 빠를 것이다! 근데 없으면 어떻게 하지? item 23의 교훈을 다시 떠올려보자 move생성자가 없어도 rvalue reference를 통해 복사생성자 호출하는 것이 가능하다. 게다가 나중에 move 생성자가 생기면 따로 코드를 바꾸지 않아도 알아서 최적화를 해주니 std::move를 안쓸 이유가 없다.

universial reference에서도 완전히 똑같은 이유에서 값 반환을 std::forward로 해주는 것이 좋다.

template<typenameT>
Fraction reduceAndCopy(T&& frac) // 인자로 universial reference
{
   frac.reduce();
#ifdef _CASE1
   return frac;  
#elseif _CASE2
   return std::forward<T>(frac); // 반환에 std::forward
#endif
}

CASE1의 경우에서 인자로 무엇이 들어오건간에 반환값은 무조건적으로 복사를 통해 생성될 것이다.

그렇다고 모든 값 반환에 std::move/forward를 써선 안된다.

여기까지 온사람들은 값반환 함수만 보면 헥헥거리면서 std::move/forward로 바꾸고 싶은 욕망을 통제할 수 없을 것이다. 하지만 참아야한다. std::move/forward가 모든것을 최적화 시키지 않는다. 오히려 더 구린 성능을 발휘할 수도 있다!

Widget makeWidget()
{
   Widget w;
   ...
#ifdef _CASE1
   return w;            //으으무...
#elseif _CASE2
   return std::move(w); //let's move!
#endif
}

의식의 흐름을 따라 주석을 작성했지만, 그렇게 생각하면 안된다고 scott meyers님이 신신당부를 하신다. 이유는 c++의 흑막 최적화 위원회 때문이다! 저기서 std::move를 쓰면 최적화 위원회에서 RVO요원이...는 죄송합니다. 함수의 반환값이 함수내의 지역변수인 경우 따로 저장공간에 복사를 해서 반환하는 것은 쓸데없이 공간을 낭비하는 것처럼 생각되지 않은가? 그래서 최적위에서는 반환 저장소대신 그냥 지역변수의 주소그대로를 전달하는 최적화를 생각해냈다. 이것이 return value optimization(이하 RVO)이다. 이것은 C++의 표준으로 오래전부터 전해져 내려왔다.

RVO는 발동 조건이 있다. 우선 지역 변수의 타입이 함수의 반환 타입과 동일할 것. 그리고 반환되는 값이 지역변수일 것. 이것만 충족시켜주면 RVO가 발동한다. RVO는 기존에 쓰던 저장공간을 그대로 전달하니까 복사하거나 move하는 것보다도 훨씬 더 매우 좋다. 그럼 다시 마음속에 RVO를 간직한 채로 makeWidget함수를 보자. CASE1의 경우 반환하는 지역변수가 반환타입이랑 동일하므로 RVO의 발동 조건을 모두 만족시킨다. 코드는 simple하지만 매우 효과적인 기능을 기대할 수 있다.

CASE2의 경우는 반환 값이 함수내의 지역변수라는 조건에 위배된다. 그래서 RVO가 진행되지 않고, w에 대한 move 생성자를 호출하여 반환값을 새로 만들어낸다. RVO에 비하여 불필요한 move가 발생하므로 최적의 코드라 말하기 어렵다. 따라서 리턴값과 같은 타입의 지역변수를 반환하는 경우 std::move를 사용하는 대신 그대로 반환시키는 것이 RVO에 의해 이득이다. 하지만 RVO는 최적화 기술이다. RVO조건이 맞아도 컴파일러가 적합한 저장소를 찾기 힘든 경우가 발생할 수 있다. 그렇게 되면 결국 copy나 move연산을 피할 수 없을 것인데, 그렇다면 역시나 std::move를 사용하는 편이 안전하지 않을까? ㄴㄴ다. RVO를 만족하지만 return 값으로 특정할 저장소를 찾기 힘든경우 컴파일러는 모든 가능한 return 지역변수들을 rvalue reference로 캐스팅하여 반환하도록 한다.

Widget makeWidget()
{
   Widget w;
   ...
   return w;  //컴파일러는 이 코드를 return std::move(w); 로 변환한다.
}

따라서 어떤 경우에도 지역변수 리턴값을 std::move로 변경하는 것은 별 도움이 안된다. 오히려 컴파일러를 귀찮게할 뿐이다. std::move와 std::forward를 써야할 곳과 쓰지 말아야할 곳을 잘 구분해서 알아두도록 하자.