본문 바로가기

컴퓨터과학

C++ (23-1) std::move와 std::forward를 숙지할 것

c++ std::move, std::forward

std::move와 std::forward를 이해하는 데에는 이들이 하지 않는 것의 관점에서 접근하는 것이 유용합니다. std::move가 모든 것을 이동하지는 않습니다. std::forward가 모든 것을 전달하지는 않습니다. 실행 시점에서는 둘 다 아무것도 하지 않습니다. 이들은 실행 가능 코드를 전혀, 단 한 바이트도 산출하지 않습니다.
std::move와 std::forward는 그냥 캐스팅을 수행하는 함수(구체적으로는 함수 템플릿)입니다. std::move는 주어진 인수를 무조건 오른값으로 캐스팅하고, std::forward는 특정 조건이 만족될 때에만 그런 캐스팅을 수행합니다. 이것이 전부입니다. 물론 이 답에서 또 다른 질문들이 비롯되긴 하지만, 기본적으로는 이것이 전부입니다.
좀 더 구체적인 논의를 위해, C++11의 std::move를 구현한 예를 봅시다. 표준의 세부사항들을 완전히 준수하는 구현은 아니지만, 그런 완전한 구현에 아주 가깝습니다. 

template<typename T> // std 이름 공간 안에서
typename remove_reference<T>::type&&
move(T&& param)
{
 using ReturnType = // 별칭 선언;
  typename remove_reference<T>::type&&; // Chapter (9) 참고

 return static_cast<ReturnType>(param);
}

독자의 이해를 돕기 위해 예제 코드에서 두 부분을 강조했습니다. 첫째로, 반환 형식 명세 부분이 다소 복잡해서 혹시 함수 이름을 잘 찾지 못할까 봐 함수 이름을 강조해 두었습니다. 둘째로, 이 함수의 핵심에 해당하는 캐스팅 부분을 강조했습니다. 강조된 부분에서 보듯이, std::move는 객체에 대한 참조(정확히 말하면 보편 참조 - Chapter (24) 참고)를 받아서 같은 객체에 대한 어떤 참조를 돌려줍니다.
함수의 반환 형식에 있는 "&&"에서 짐작했겠지만, std::move는 하나의 오른 값 참조를 돌려줍니다. 그러나 Chapter (28)에서 설명하듯이, 형식 T가 하필 왼 값 참조이면
T&&는 왼값 참조가 됩니다. 이를 방지하기 위해, 이 구현은 T에 형식 특질(Chapter (9) 참고) std::move_reference를 적용합니다. 그러면 반환 형식의 "&&"는 항상 참조가 아닌 형식에 적용됩니다. 결과적으로, std::move는 반드시 오른 값 참조를 돌려줍니다. 이 점이 중요한 것은, 함수가 돌려준 오른 값 참조는 오른 값이기 때문입니다. 결론적으로, std::move는 자신의 인수를 오른 값으로 캐스팅합니다. 그것이 std::move가 하는 일의 전부입니다.
참고로 C++14에서는 std::move를 이보다 간결하게 구현할 수 있습니다. 함수 반환 형식 연역(Chapter (3) 참고)과 표준 라이브러리의 별칭 템플릿들 중 하나인 std::remove_reference_t(Chapter (9) 참고) 덕분에 std::move를 다음과 같이 작성할 수 있습니다.

template<typename T> // C++14; 여전히 std 이름 공간 안에서
decltype(auto) move(T&& param)
{
 using ReturnType = remove_reference_t<T>&&;
 return static_cast<ReturnType>(param);
}

언뜻 보기에도 이쪽이 더 쉽습니다.
std::move가 하는 일이 자신의 인수를 오른값으로 캐스팅하는 것뿐이라면 점 때문에, move보다는 rvalue_cast 같은 이름이 더 낫다는 제안도 있었습니다. 합당한 제안이긴 하지만, 어쨌든 현재 표준으로 정해진 이름은 std::move이므로, 현실적으로 중요한 것은 이름만 보고 이 함수가 하는 일과 하지 않는 일을 지례 짐작하지 않는 것입니다. std::move는 캐스팅을 수행하지만, 이동(move)은 수행하지 않습니다.
물론 오른값은 이동의 후보이며, 따라서 어떤 객체에 std::move를 적용한다는 것은 컴파일러에게 그 객체가 이동에 적합하다는 점을 말해주는 것에 해당합니다. 즉, std::move라는 이름은 이동할 수 있는 객체를 좀 더 쉽게 지정하기 위한 함수라는 점에서 붙은 것입니다.
그러나 사실 오른 값이 이동의 후보가 아닌 경우도 있습니다. 주해(annotation)를 나타내는 어떤 클래스의 생성자가 주해의 내용을 구성하는 std::string 매개변수 하나를 받아서 그 매개변수를 자료 멤버에 복사한다고 합시다. Chapter (41)에 나온 정보에 기초해서, 그 매개변수를 값 전달 방식으로 선언하기로 합시다.

class Annotation {
public:
 explicit Annotation(std::string text); // 복사할 매개변수 Chapter (41)에 따라 값 전달로 선언
 ...
};

그런데 이 Annotation의 생성자는 text의 값을 읽기만 하면 됩니다. 그 값을 수정할 필요는 없습니다. 가능한 한 항상 const를 사용한다는 유서 깊은 전통에 따라, text 매개변수가 const가 되도록 선언을 수정합시다.

class Annotation {
public:
 explict Annotation(const std::string text);
 ...
};

text를 자료 멤버에 복사할 때 복사 연산의 비용을 치르지 않으려면, 그러면서도 Chapter (41)의 조언을 계속 지키려면 어떻게 해야 할까요?? 다음처럼 std::move를 text에 적용해서 오른 값을 얻으면 어떨까요??

class Annotation {
public:
 explict Annotation(const std::string text)
 : value(std::move(text)) // text를 value로 '이동'한다;
 { ... } // 이 코드는 보기와는 다르게 작동한다!
 ...

private:
 std::string value;
};

이 코드의 컴파일과 링크에는 아무 문제가 없으며, 실행도 잘 됩니다. 이 코드는 자료 멤버 value를 text의 내용으로 설정합니다. 이 코드가 독자의 의도를 완벽하게 실현하지 못하는 유일한 결함은, text가 value로 이동하는 것이 아니라 복사된다는 점입니다. 
std::move 때문에 text가 오른값으로 캐스팅되는 것은 확실합니다. 그런데 text는 const std::string으로 선언되었으므로, 캐스팅 이전에는 왼 값 const std::string이고, 캐스팅한 결과는 오른 값 const std::string입니다. 즉, 전체 과정에서 const가 그대로 유지됩니다.
컴파일러가 std::string 생성자 중 하나를 선택할 때 const의 존재가 어떤 영향을 미치는지 생각해 봅시다. 가능성은 두 가지 입니다.

class string { // std::string은 사실 std::basic_string<char>의 typedef임
public:
 ...
 string(const string& rhs); // 복사 생성자
 string(string&& rhs); // 이동 생성자
 ...
};
Annotation 생성자의 멤버 초기화 목록에서 std::move(text)의 결과는 const std::string 형식의 오른 값입니다. 그 오른 값은 std::string의 이동 생성자에 전달할 수 없습니다.
왜냐하면, 그 이동 생성자는 const가 아닌 std::string에 대한 오른 값 참조를 받기 때문입니다. 
그러나 그 오른값을 복사 생성자에 전달할 수는 있습니다. const에 대한 왼 값 참조를 const 오른 값에 묶는 것이 허용되기 때문입니다.