[Modern C++] 항목 23: std::move와 std::forward를 숙지하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 5. 오른값(rvalue) 참조, 이동 개념, 완벽 전달
🧠 도입부
- 이동 개념(move semantics): 비싼 복사 연산을 더 싼 이동 연산으로 대체 가능하게 하는 개념.
std::unique_ptr,std::future,std::thread같은 이동 전용 타입 작성을 가능케 한다. - 완벽 전달(perfect forwarding): 임의의 인수들을 받아 다른 함수(대상 함수)로 전달할 때, 대상 함수가 애초에 전달 함수가 받은 것과 정확히 같은 인수들을 받게 만드는 것.
- rvalue 참조는 이 두 기능을 하나로 묶는 언어 메커니즘이다.
- 주의할 점:
std::move가 모든 것을 이동시키는 것은 아니며,std::forward도 완벽하지 않다. 이동 연산이 항상 복사보다 싼 것도 아니고, 이동이 가능한 문맥에서 항상 이동이 일어나는 것도 아니다. - 매개변수는 항상 lvalue: 타입이 rvalue 참조라도 매개변수 자체는 lvalue다.
void f(Widget&& w); // w는 rvalue 참조 타입이지만, w 자체는 lvalue다
👉🏻 항목 23: std::move와 std::forward를 숙지하라
🔍 핵심 개념
std::move와std::forward는 캐스팅만 수행하는 함수 템플릿이다. 실행 시점에는 아무 일도 하지 않으며, 실행 가능 코드를 단 한 바이트도 산출하지 않는다.std::move: 주어진 인수를 무조건 rvalue로 캐스팅한다.std::forward: 주어진 인수가 rvalue에 묶인 경우에만 rvalue로 캐스팅한다 (조건부 캐스팅).
// C++11 구현 (std 이름공간 안에서)
template<typename T>
typename remove_reference<T>::type&&
move(T&& param)
{
using ReturnType = typename remove_reference<T>::type&&; // 항목 9 참고
return static_cast<ReturnType>(param);
}
// C++14 구현 (더 간결)
template<typename T>
decltype(auto) move(T&& param)
{
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}
remove_reference를 적용하는 이유: 타입T가 lvalue 참조이면T&&가 lvalue 참조가 되어버리는 것을 방지하기 위함(항목 28 참고). 그 덕분에std::move는 항상 rvalue 참조를 반환한다.
🔍 std::move의 함정 — const 객체
std::move는 캐스팅만 수행할 뿐, 대상 객체가 실제로 이동 가능함을 보장하지 않는다.
class Annotation {
public:
explicit Annotation(const std::string text)
: value(std::move(text)) // text를 value로 '이동'한다
{ … } // 그러나 이 코드는 보기와 다르게 작동한다!
private:
std::string value;
};
text는const std::string이므로std::move(text)의 결과는const std::string&&, 즉 const rvalue다.std::string의 이동 생성자는const가 아닌std::string&&을 받으므로 이 const rvalue를 받을 수 없다.- 반면 복사 생성자는
const std::string&을 받으므로, const rvalue를 lvalue 참조로 바인딩할 수 있어 결국 복사 생성자가 호출된다. - 교훈 2가지
- 이동을 지원할 객체는
const로 선언하지 말 것 — const 객체에 대한 이동 요청은 조용히 복사로 바뀐다. std::move는 아무것도 실제로 이동하지 않으며, 대상이 이동 자격을 갖추게 된다는 보장도 없다. 확실한 것은 결과가 rvalue라는 것뿐이다.
- 이동을 지원할 객체는
🔍 std::forward의 전형적 용법 — 조건부 캐스팅
void process(const Widget& lvalArg); // lvalue 처리 함수
void process(Widget&& rvalArg); // rvalue 처리 함수
template<typename T>
void logAndProcess(T&& param) // param을 process에 넘겨주는 템플릿
{
auto now = std::chrono::system_clock::now(); // 현재 시간을 얻는다
makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}
Widget w;
logAndProcess(w); // lvalue로 호출
logAndProcess(std::move(w)); // rvalue로 호출
param은 매개변수이므로 항상 lvalue다. 따라서 그냥process(param)으로 호출하면 항상 lvalue 버전만 실행된다.logAndProcess에 전달된 인수가 rvalue였을 때만param을 rvalue로 캐스팅해야 하며, 이를 수행하는 것이std::forward다.std::forward는 인수가 rvalue로 초기화되었는지를 템플릿 매개변수T에 부호화(encoding)된 정보를 통해 판단한다(구체적 방법은 항목 28 참고).
🔍 std::move vs std::forward — 왜 둘 다 필요한가
- 이론적으로는
std::forward하나로std::move의 역할까지 대체할 수 있다. 그러나 다음 이유로 둘을 구분해서 사용한다.
// std::move 사용 (통상적 방식)
class Widget {
public:
Widget(Widget&& rhs)
: s(std::move(rhs.s))
{ ++moveCtorCalls; }
private:
static std::size_t moveCtorCalls;
std::string s;
};
// std::forward로 같은 일을 하면 (관례에서 벗어난, 바람직하지 않은 구현)
class Widget {
public:
Widget(Widget&& rhs)
: s(std::forward<std::string>(rhs.s)) // 타입 인수까지 지정해야 함
{ ++moveCtorCalls; }
};
std::move는 함수 인수 하나만 지정하면 되지만,std::forward는 함수 인수와 템플릿 타입 인수를 모두 지정해야 한다. 잘못된 타입을 지정하면(예:std::string&) 이동이 아니라 복사가 일어나는 실수로 이어질 수 있다.- 더 중요한 것은 의미의 차이다:
std::move는 인수를 무조건 rvalue로 캐스팅하여 하나의 이동을 준비하는 것이고,std::forward는 인수가 원래 lvalue/rvalue였는지 성질을 유지한 채로 다른 함수에 그대로 넘겨주는(forward) 것이다. 서로 다른 동작이므로 구별되는 함수(이름)를 두는 것이 바람직하다.
🧐 정리
std::move는 rvalue로의 무조건 캐스팅을 수행한다.std::move자체는 아무것도 이동하지 않는다.std::forward는 주어진 인수가 rvalue에 묶인 경우에만 그것을 rvalue로 캐스팅한다.std::move와std::forward둘 다, 실행시점에는 아무 일도 하지 않는다.
댓글남기기