[Modern C++] 항목 23: std::move와 std::forward를 숙지하라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 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::movestd::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;
};
  • textconst std::string이므로 std::move(text)의 결과는 const std::string&&, 즉 const rvalue다.
  • std::string의 이동 생성자는 const가 아닌 std::string&&을 받으므로 이 const rvalue를 받을 수 없다.
  • 반면 복사 생성자는 const std::string&을 받으므로, const rvalue를 lvalue 참조로 바인딩할 수 있어 결국 복사 생성자가 호출된다.
  • 교훈 2가지
    1. 이동을 지원할 객체는 const로 선언하지 말 것 — const 객체에 대한 이동 요청은 조용히 복사로 바뀐다.
    2. 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::movestd::forward 둘 다, 실행시점에는 아무 일도 하지 않는다.

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

댓글남기기