본문 바로가기
PROGRAMMING/Effective Modern C++

23. std::move와 std::forward를 숙지하라.

by La-KanTo 2021. 3. 13.

- std::move는 우측값으로의 무조건 캐스팅을 수행한다. std::move 자체는 아무것도 이동하지 않는다.

 C++11의 std::move를 구현한 예를 보자.
표준의 세부사항들을 완전히 준수하는 구현은 아니지만, 그런 완전한 구현에 아주 가깝다

template <typename T>
typename std::remove_reference<T>::type&&
move(T&& param)
{
    using ReturnType = typename std::remove_reference<T>::type&&;
    return static_cast<ReturnType>(param);
}

항목 28에서 설명하듯이, 타입 T가 하필 좌측값 참조이면 T&&는 좌측값 참조가 된다.
이를 방지하기 위해, 이 구현은 T에 타입 특성 (항목 9 참고) std::remove_reference를 적용한다.

그러면 반환 타입의 "&&"는 항상 참조가 아닌 타입에 적용된다.
결과적으로 std::move는 반드시 우측값 참조를 돌려준다.
이처럼 std::move가 하는 일은, 자신의 인수를 우측값으로 캐스팅하는 것 뿐이다.

 

참고로 C++14에서는 std::move를 더 간결하게 구현할 수 있다.
함수 반환 타입 추론(항목 3 참고)과 표준 라이브러리의 별칭 템플릿 중 하나인 std::remove_reference_t(항목 9 참고)

덕분에 std::move를 다음과 같이 작성할 수 있다.

template <typename T>
decltype(auto) move(T&& param)
{
    using ReturnType = remove_reference_t<T>&&;
    return static_cast<ReturnType>(param);
}

사실 우측값이 이동의 후보가 아닌 경우도 있다. 바로 const T 타입의 우측값이다. 이동 생성자는 const가 아닌 T에 대한 우측값 참조를 받기 때문에 const T 타입의 우측값을 받을 수 없다. 그러나 const에 대한 좌측값 참조를 const 우측값에 묶는 것이 허용되기 때문에, 복사 생성자에 전달할 수는 있다.

일반적으로 한 객체의 값을 바깥으로 이동하면 그 객체는 수정되며, 따라서 전달된 객체를 수정할 수도 있는 함수(이동 생성자가 그런 함수에 속한다)에 const 객체를 전달하는 일을 C++ 언어가 방지하는 것은 당연한 일이다.

 

주석(annotation)을 나타내는 어떤 클래스의 생성자가 주석의 내용을 구성하는 std::string 매개변수 하나를 받아서 그 매개변수를 데이터 멤버에 복사한다고 하자.


항목 41에 나온 정보에 기초해서, 그 매개변수를 값 전달 방식으로 선언하기로 하자.

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

그런데 이 Annotation의 생성자는 text의 값을 읽기만 한다.
따라서 매개변수 text가 const가 되도록 선언문을 수정하자.

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

text를 데이터 멤버로 복사할 때 복사 연산의 비용을 치르지 않으려면, 그러면서도 항목 41의 조언을 계속 지키려면 어떻게 해야 할까?
다음처럼 std::move를 text에 적용해서 우측값을 얻으면 어떨까?

class Annotation {
public:
    explicit Annotation(const std::string text)
        : value(std::move(text)) {}

private:
    std::string value;
};

이 코드의 컴파일과 링크에는 아무 문제가 없으며, 실행도 잘 된다.
그런데 text가 value로 이동하는 것이 아니라 복사된다는 점이 문제이다.

text는 const std::string으로 선언되었으므로, 캐스팅 이전에는 좌측값 const std::string이고, 캐스팅한 결과는 우측값 const std::string이다.
즉, 전체 과정에서 const가 그대로 유지된다.
std::string 클래스는 다음과 같이 생겼을 것이다.

// std::string은 사실 std::basic_string<char>의 typedef임
class string { 
public:                        
    string(const string& rhs); // 복사 생성자
    string(string&& rhs); // 이동 생성자
};

이런 상황에서 우측값 const std::string을 생성자로 전달하면, const 때문에 이동 생성자가 아닌 복사 생성자가 호출되게 된다.

 

이 예에서 배울 점이 두 가지 있다. 첫째로, 이동을 지원할 객체는 const로 선언하지 말아야 한다. const 객체에 대한 이동 요청은 소리 없이 복사 연산으로 변환된다. 둘째로, std::move는 아무것도 실제로 이동하지 않을 뿐만 아니라, 캐스팅되는 객체가 이동 자격을 갖추게 된다는 보장도 제공하지 않는다.

 

- std::forward는 주어진 인수가 우측값에 묶인 경우에만 그것을 우측값으로 캐스팅한다.

std::forward는 주어진 인수가 우측값에 묶인 경우에만 그것을 우측값으로 캐스팅한다.

template <typename T> // param을 process에 넘겨주는 템플릿
void logAndProcess(T&& param) 
{
    auto now = std::chrono::system_clock::now();
    makeLogEntry("Calling 'process'", now);
    process(std::forward<T>(param));
}

이제 logAndProcess를 한 번은 좌측값으로, 또 한 번은 우측값으로 호출해보자.

Widget w;
logAndProcess(w); // 좌측값으로 호출
logAndProcess(std::move(w)); // 우측값으로 호출

좌측값으로 호출한 경우, logAndProcess는 좌측값들을 처리하는 process 함수를 호출하고, 우측값으로 호출한 경우, logAndProcess는 우측값들을 처리하는 process 함수를 호출한다.

param은 좌측값이므로 std::forward를 사용하지 않는다면, 항상 좌측값들을 처리하는 process 함수가 호출될 것이다.
std::forward는 param을 초기화하는데 쓰인 인수가 우측값이면, 그리고 오직 그럴때에만, param을 우측값으로 캐스팅한다. std::forward는 param을 초기화하는데 쓰인 인수가 우측값인지를 어떻게 알까?
간단한 답은, 그 정보가 logAndProcess의 템플릿 매개변수 T에 부호화(encoding)되어 있다는 것이다.

그 매개변수는 std::forward로 전달되며, std::forward는 거기서 해당 정보를 복원한다(구체적인 방법은 항목 28을 보기 바란다).

 

std::move 대신 항상 std::forward만 쓰면 되지 않을까? 라고 생각할지도 모른다.
std::move의 매력은 사용하기 편하고, 오류의 여지가 줄어들고, 코드의 명확성이 높아진다는 것이다.

어떤 클래스의 이동 생성자가 호출된 횟수를 추척하고 싶다고 하자.
그러러면 그냥 이동 생성 도중에 클래스 정적(static) 카운터 변수를 증가하면 된다.
클래스의 비정적 데이터 멤버가 std::string 하나뿐이라 할 때, 다음은 이동 생성자를 구현하는 통상적인 방식을 보여준다.

class Widget {
public:
    Widget(Widget&& rhs)
        : s(std::move(rhs.s))
    {
        ++moveCtorCalls;
    }

private:
    static std::size_t moveCtorCalls;
    std::string s;
};

std::move 대신 std::forward를 쓰면 다음과 같은 코드가 될 것이다.

class Widget {
public:
    // 관례에서 벗어난, 바람직하지 않은
    Widget(Widget&& rhs) : s(std::forward<std::string>(rhs.s))
    {
        ++moveCtorCalls;
    }
};

첫번째 버전의 std::move에서는 함수 인수 (rhs.s)만 지정하면 되었지만 둘째 버전의 std::forward에서는 함수 인수(rhs.s)와 템플릿 타입 인수(std::string) 둘 다 지정해야 했음을 주목하기 바란다(번거롭다).

그리고 std::forward에 전달하는 타입이 반드시 참조가 아니어야 한다는 점도 주목하기 바란다(실수로 std::string&을 전달하면 이동이 아닌 복사가 일어난다).

그것이 전달되는 인수가 우측값임을 부호화하는 데 쓰이는 관례이기 때문이다(항목 28 참고).

 

- std::move와 std::forward 둘 다, 실행시점에서는 아무 일도 하지 않는다.

댓글