ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] std::move와 std::forward
    C++/Effective Modern C++ 2022. 6. 30. 14:02

    C++11에 제안된 이동 시맨틱(Move semantics)은 이동 생성자와 이동 배정 연산자를 사용합니다.

    위 함수들을 사용함으로써 복사 연산을 이동 연산으로 대체하는 것에 의의가 있으며, 이것은 함수의 비용이 줄어든다는 장점을 가지고 있습니다.

    또한 복사 연산을 제한하는 객체 (std::unique_ptr과 같은)를 이동 연산으로 이동할 수 있다는 특징이 있습니다.

     

    이번 글에서는 이동 시맨틱에 사용되는 함수들인 std::move와 std::forward에 대해 살펴볼 것 입니다.

     


     

    1. 개요

     

    더보기

    std::move와 std::forward는 각각 이동, 전달이라는 의미를 가진 함수입니다.

    하지만, 이들 함수가 실제로 이동과 전달을 수행하지는 않습니다.

    이들 함수를 사용한 코드의 결과를 보면 이동과 전달이라고 할 수 있지만, 이들이 실제로 수행하는 것은 캐스팅입니다.

    두 함수 모두 주어진 인수를 r-value로 캐스팅하는데, std::move는 무조건적인 캐스팅을 수행하고, std::forward는 특정 조건 하에서만 캐스팅을 수행한다는 점에서 차이가 존재합니다.

    추가적으로, 두 함수는 실행 시점에는 아무 행동도 하지 않고, 아무 코드도 산출하지 않습니다.

     

    std::move의 구현에 대한 예시를 살펴보도록 하겠습니다.

    // C++11
    template<typename T>
    typename remove_reference<T>::type&& move(T&& param) {
        using ReturnType = typename remove_reference<T>::type&&;
        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);
    }

    이 함수는 표준에 가까운 std::move의 구현입니다.

    보시는 바와 같이, T가 l-value참조일 경우에 대비해 std::remove_reference를 적용한 뒤 r-value참조에 대한 캐스팅을 수행합니다.

     

    다음 문단부터, 이들의 이름으로 유추할 수 있는 기능인 "이동"과 "전달", 그리고 "r-value 캐스팅"의 차이를 살펴보도록 하겠습니다.

     

    2. std::move : 이동을 하지 않는 경우

     

    더보기

    위 문단의 내용처럼, std::move함수는 r-value캐스팅을 수행합니다.

    r-value는 이동에 사용되는 형식이기 때문에, std::move를 사용하는 것은 이동할 수 있는 객체를 쉽게 지정하기 위한 함수라고 볼 수 있습니다.

    하지만, r-value가 항상 이동을 수행하지 않는다는 점을 중심으로 특정 예제를 살펴보고자 합니다.

     

    아래 예제의 클래스 Annotation은 주석의 내용을 구성하는 std::string 문자열을 매개변수로 받아 생성됩니다.

    // First
    class Annotation {
    public:
        explicit Annotation(std::string text);
        ...
    };
    
    // Second
    class Annotation {
    public:
        explicit Annotation(const std::string text); // Add const
        ...
    };
    
    // Third
    class Annotation {
    public:
        explicit Annotation(const std::string text) : value(std::move(text) { ... }; // Add move
        ...
    private:
        std::string value;
    };

    각각의 생성자는 아래와 같은 이유를 근거로 수정되었습니다.

    • 전달받은 값에 대한 수정이 필요없기 때문에, 두 번째 수정 시 매개변수를 const로 선언했습니다.
    • 복사 연산보다 이동 연산이 저렴하기 때문에, 세 번째 수정 시 매개변수의 초기화에 std::move를 사용했습니다.

    위 코드의 세 번째 수정본을 이용한 클래스는 정상적으로 컴파일되며, 실행도 문제없이 됩니다.

    인자로 받은 text는 value의 초기화에 적절히 사용됩니다.

    하지만, text가 value를 초기화 할 때 이동이 아니라 복사가 된다는 점을 생각해야 합니다.

     

    다음 문단에서 이어집니다.

     

    3. std::move 이해하기

     

    더보기

    위 문단에서 std::move를 사용했음에도 이동이 아닌 복사가 수행되는 예시를 살펴보았습니다.

    이 예시에 대한 추가적인 해설을 위해 std::string 함수의 생성자를 간략히 정의해보겠습니다.

    class string {
    public:
        ...
        string(const string& rhs); // Copy construct
        string(string&& rhs); // Move construct
        ...
    };

    위 문단의 std::move(text)의 결과는 const std::string형식의 r-value입니다. 

    이동 생성자는 const가 아닌 std::string형식의 r-value참조를 받기 때문에, std::move(text)의 결과물은 이동 생성자에 전달될 수 없습니다.

    하지만 const에 대한 l-value참조를 const에 대한 r-value에 묶을 수 있기 때문에, 복사 생성자에는 전달이 가능합니다.

    위와 같은 이유로 std::string의 복사 생성자가 호출이 되고, Annotation 클래스의 value 초기화에 이동이 아닌 복사가 수행됩니다.

     

    따라서, 이동을 지원할 객체는 const로 선언하지 않는 것을 유의해야 합니다.

    이동 요청은 복사 연산으로 통보 없이 대체될 수 있으며, 이러한 상황이 많아질수록 퍼포먼스가 나빠질 수 있습니다.

    또한, std::move가 이동을 수행하지 않고, 이동을 보장해주지 않는다는 것을 유의해야 합니다.

    std::move의 수행 결과로 확신할 수 있는 것은 std::move의 결과가 r-value라는 것 뿐입니다.

     

    4. std::forward

     

    더보기

    std::forward또한 std::move와 유사합니다.

    다만 다른점은, std::forward는 특정 조건 하에서만 캐스팅이 된다는 점 입니다.

     

    예제를 살펴보도록 하겠습니다.

    void process(const MyClass& lvalArg);
    void process(MyClass&& rvalArg);
    
    template<typename T>
    void logAndProcess(T&& param) {
        auto now = std::chrono::system_clock::now();
        makeLogEntry("Calling 'process'", now);
        //process(param);
        process(std::forward<T>(param));
    }

    r-value와 l-value를 따로 받는 오버로딩 된 함수 process와, 그것을 호출하는 logAndProcess함수입니다.

    makeLogEntry함수는 로그를 작성해주는 함수라고 가정하겠습니다.

    위의 함수를 아래와 같이 r-value, l-value에 대하여 각각 호출한다고 가정하겠습니다.

    MyClass c;
    
    logAndProcess(w); // Case 1
    logAndProcess(std::move(w)); // Case 2

    Case 1은 l-value호출이고, Case 2는 r-value 호출입니다.

    두 호출에 대하여, logAndProcess함수가 각각 다른 종류의 process함수를 호출할 것을 기대할 수 있습니다. 

     

    하지만 위 호출에 대하여 logAndProcess가 std::forward를 사용하지 않고 process를 호출할 경우, l-value에 대한 process함수만을 호출하는 것을 볼 수 있습니다.

    이것은 logAndProcess의 param이 다른 모든 함수의 매개변수처럼 l-value이기 때문입니다.

    따라서 param을 초기화 할 때 사용된 인수 (logAndProcess에 전달된 인수)가 r-value일 때, param을 r-value로 캐스팅하는 특정한 방법이 필요하고, 이것이 std::forward의 역할입니다.

     

    다음 문단에서 이어집니다.

     

    5. std::forward의 r-value 판단

     

    더보기

    위 문단에서의 예제를 통해 특정 경우에 캐스팅이 필요할 경우 std::forward를 수행한다는 점을 알 수 있었습니다.

    이 때, std::forward가 인자의 r-value여부를 판단하는 방법은 참조 축약이라는 규칙과 관련되어 있습니다.

    참조 축약에 대해서는 다른 글에서 다루도록 하겠습니다.

    이 글에서는 참조 축약이란 참조에 대한 참조가 특정 참조로 산출된다는 점과 함께, 아래 예제를 살펴보도록 하겠습니다.

    template<typename T>
    T&& forward(typename remove_reference<T>::type& param) {
        return static_cast<T&&>(param);
    }

    위 구현은 std::forward의 가능한 구현 중 하나입니다.

    참조 축약은, 참조에 대한 참조를 특정 참조로 축약하는 것 입니다. 참조는 r-value참조와 l-value 참조의 두 가지가 존재하므로, 참조 축약이 가능한 경우의 수는 4가지 입니다.

    인수 매개변수 결과
    R-value R-value R-value
    R-value L-value L-value
    L-value R-value L-value
    L-value L-value L-value

    위의 표에 정리되어 있듯이, 참조에 l-value가 존재할 경우 결과는 l-value입니다.

    이것을 위의 std::forward함수에 대입하여, 각각 l-value와 r-value호출을 가정하면 아래와 같은 함수가 됩니다.

    // l-value call
    MyClass& forward(MyClass& param) {
        return static_cast<MyClass&>(param); }
    
    // r-value call
    MyClass&& forward(MyClass& param) {
        return static_cast<MyClass&&>(param); }

    std::forward함수는 참조 축약에 의해 위와 같은 형태로 r-value에 대하여 r-value참조를 반환합니다.

     


     

     

    기술적으로 std::move와 std::forward사이에 독립적으로, 자신만이 할 수 있는 기능이 존재하지는 않습니다.

    std::forward는 std::move를 완전히 대체할 수 있습니다.

    거기에 더해, 두 함수 모두 기술적인 의미에서는 필요로 하지 않습니다.

    사용자가 전달하는 인수를 직접 캐스팅 하는 것으로 해결할 수 있기 때문입니다.

     

    하지만 코드의 가독성 관점에서 살펴보면 그렇지 않습니다.

    std::move는 주어진 인수를 무조건 r-value로 캐스팅하는 함수입니다.

    std::forward는 주어진 인수가 r-value일 경우 r-value로 캐스팅하는 함수입니다.

    이동하는 것과, 전달하는 것은 코드의 의미를 중점으로 살펴보면 완벽히 다르다고 볼 수 있습니다.

     

    감사합니다.

    댓글

Designed by Tistory.