ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] std::move와 std::forward (2)
    C++/Effective Modern C++ 2022. 7. 25. 11:16

    이전 글 (std::move와 std::forward)에서 std::move와 std::forward에 대하여 알아보았습니다.

    이동 시맨틱에서 이동 및 전달을 보조해주는 함수들로, 이름과는 달리 상황에 따른 R-value 캐스팅을 수행합니다.

    이전 글에서 각각의 함수의 수행 기능, 의의를 살펴보았다면

    이번 글에서는 각각의 함수를 사용하는 상황에 대한 예시를 몇 가지 살펴보도록 하겠습니다.

     


     

    1. R-value reference에는 std::move

     

    더보기

    특정 매개변수가 R-value 참조라면, 그 참조에 묶인 객체는 반드시 이동할 수 있습니다.

    class MyClass {
        MyClass(MyClass&& rhs);
        ...
    };

    위와 같이, 함수가 객체의 R-value성질을 활용할 수 있어야 하는 경우, 매개변수를 R-value로 캐스팅해야 합니다.

    class MyClass {
    public:
        MyClass(MyClass&& rhs) : name(std::move(rhs.name)) { ... }
        ...
    private:
        std::string name;
    };

    이런 경우에 std::move를 사용합니다. (std::move의 이동은 실제로는 R-value캐스팅을 의미합니다.)

     

     

    2. Universal reference에는 std::forward

     

    더보기

    보편참조를 매개변수로 받는 경우, 객체가 이동에 적합하지 않을 수 있습니다.

    이런 경우 객체가 R-value로 초기화 되는 경우에만 R-value로 캐스팅 해야 합니다.

    class MyClass {
    public:
        template<typename T>
        void setName(T&& newName) {
            name = std::forward<T>(newName);
        }
        ...
    };

    std::forward는 객체가 R-value일 경우 R-value로 캐스팅 하는 조건부 캐스팅 함수입니다.

    위와 같이 이동 여부가 갈릴 경우 std::forward를 사용합니다.

     

     

    3. 만약 반대로 사용한다면?

     

    더보기

    이전 글의 말미에 언급했듯이, std::forward는 기술적으로 std::move를 완전히 대체할 수 있습니다.

    R-value reference의 경우 std::forward를 사용할 수 있다는 말과 동치입니다.

    하지만 가독성 및 관용적 표현에서 적합하지 않습니다.

     

    반대로 보편참조에서 std::move를 사용하는 경우는 상황이 조금 심각해집니다.

    class MyClass {
    public:
        template<typename T>
        void setName(T&& newName) {
            name = std::move(newName); 
        }
        ...
    };
    
    ...
    
    std::string makeString();
    MyClass c;
    auto n = makeString();
    c.setName(n);
    
    ...

    위 문단의 std::forward를 사용했던 setName함수에 std::move를 적용한 모습입니다.

    아래 코드는 팩토리 함수로 std::string을 생성하고, 그 값으로 c를 수정하는 코드입니다.

    위 코드는 결과적으로 n이 c.name으로 이동하게 되면서 미지정 값이 됩니다.

     

    결과적으로 위 코드는 std::move가 아닌 std::forward를 사용하는 것이 맞지만 다른 방법을 시도해보도록 하겠습니다.

    문제가 n이 수정됨으로 인해 발생했으므로, 매개변수의 변경을 막기 위해 const를 사용해보도록 하겠습니다.

    하지만 이 경우, 보편참조는 const가 될 수 없으므로 setName함수를 R-value와 L-value로 오버로딩 해야 합니다.

    class MyClass {
    public
        void setName(const std::string& newName) {
            name = newName; 
        }
        void setName(std::string&& newName) {
            name = std::move(newName);
        }
        ...
    };

    위 코드는 몇 가지 문제점을 가지고 있습니다.

    첫 번째로, 유지보수 해야 할 함수가 한개가 아니라 두개가 되었습니다.

    두 번째로, 효율성에 문제가 발생합니다..

    위 함수를 아래와 같이 호출하도록 하겠습니다.

    c.setName("Name");

    보편참조를 사용한 setName은 문자열 "Name"이 그대로 setName에 전달되고, c의 std::string 멤버에 대한 배정 연산자의 인수로 사용됩니다. 이 과정에서 std::string객체가 추가로 생성되지 않습니다.

    하지만 오버로딩 된 함수의 경우 std::string 임시 객체가 생성되고, c의 std::string 멤버로 이동된 후, 임시 객체를 소멸하기 위한 소멸자가 호출됩니다. 이러한 과정은 std::string에 대한 배정 연산자 한번보다 클 것이라고 예상할 수 있습니다.

    마지막으로, 설계 관점에서 가변성(Scalability)이 좋지 않습니다.

    유지보수, 성능과 별개로 위와 같은 오버로딩을 통한 구현은 매개변수가 늘어날 경우 늘어나는 오버로딩 된 함수의 갯수가 기하급수적으로 증가합니다. 위와 같이 매개변수가 한 개인 함수는 2개의 함수로 오버로딩 할 수 있지만, 매개변수의 갯수가 늘어날수록 오버로딩 해야하는 함수가 기하급수적으로 늘어납니다.

    심지어, C++ 함수 템플릿 중에는 매개변수를 무제한으로 받을 수 있는 예약어가 존재합니다. 다음은 그것을 사용한 std::make_shared의 선언입니다.

    template<class T, class... Args>
    shared_ptr<T> make_shared(Args&&... args);

    이러한 경우에 대해서는 R-value와 L-value로 나누는 것이 거의 불가능합니다.

     

    조금 멀리 돌아왔습니다. 결론은 R-value와 L-value를 모두 받는 함수는 보편참조로 선언해야 한다는 것 입니다.

    따라서 위에 살펴본 예제의 경우는 함수를 고치는 것 보다는, 호출 측의 std::move를 std::forward로 고치는 것이 좋습니다.

     

    3. 반환값 최적화 (Return Value Optimization, RVO) : 매개변수의 경우

     

    더보기

    함수의 반환값이 값이고 (Return by value), 그것이 R-value 혹은 보편참조라면 return문에 std::move 혹은 std::forward를 사용하는 것이 좋습니다.

    다음 함수를 살펴보도록 하겠습니다.

    Matrix operator+(Matrix&& lhs, const Matrix& rhs) {
        lhs += rhs;
        return std::move(lhs);
    }

    위 operator+함수는 두 행렬을 더하는 함수인데, 좌변이 R-value입니다. 따라서, 좌변의 메모리를 +연산의 결과를 담는 데 재사용 할 수 있습니다.

    이 경우, std::move를 통해 R-value 캐스팅을 수행하여 lhs를 함수의 반환값으로 이동할 수 있습니다.

    만약 std::move가 없이 lhs를 반환한다면, L-value인 lhs를 반환값으로 복사해야 할 것입니다.

    Matrix객체의 이동 연산이 복사 연산보다 가벼울 경우, std::move를 사용하지 않을 경우 성능 하락이 있을 것입니다.

    Matrix객체가 이동 연산을 지원하지 않는다고 해도 아무 문제 없이 복사 연산으로 대체되기 때문에 std::move의 사용을 주저할 필요는 없습니다.

     

    R-value가 아닌 보편 참조를 살펴보도록 하겠습니다.

    template<typename T>
    Matrix function(T&& matrix) {
        matrix.transformation();
        return std::forward<T>(matrix);
    }

    위 함수는 인자로 받은 matrix에 특정 변환을 수행하는 함수입니다.

    이 경우에도 R-value와 유사하게 R-value일 경우 이동이, L-value일 경우 복사가 이루어집니다.

     

    4. 반환값 최적화 (Return Value Optimization, RVO) : 지역변수의 경우

     

    더보기

    위 문단에서는 함수의 매개변수를 반환값에 사용할 경우, 이동 연산을 통해 효율을 높일 수 있는 것을 살펴보았습니다.

    하지만 지역변수에 대해서는 조금 다릅니다.

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

    // Before
    MyClass function() {
        MyClass c;
        ...
        return c;
    }
    
    // After
    MyClass function() {
        MyClass c;
        ...
        return std::move(c);
    }

    위와 같이 반환값의 복사를 막기 위해 지역변수에 std::move를 적용하는 경우입니다.

    위와 같은 추론을 하는 것은 나쁘지 않지만, 이것이 매개변수와 다른 이유는 표준 때문입니다.

    C++표준은 위와 같은 경우(지역변수를 반환값 메모리에 생성할 경우 복사를 피할 수 있다)에 대한 최적화를 컴파일러가 수행할 수 있도록 승인했습니다. 이것이 RVO입니다.

    위 최적화를 수행하려면 구체적으로 두 가지 조건을 만족해야 합니다.

    1. 지역 객체의 형식이 함수의 반환 형식과 같아야 한다.
    2. 지역 객체가 곧 함수의 반환값이어야 한다.

    지역 객체는 지역변수 뿐만 아니라, return문에 사용될 임시 객체도 포함됩니다.

    RVO를 수행하는 객체가 지역변수인지, 임시 객체인지에 따라 NRVO(Named RVO)와 RVO로 구분하기도 하는데, 이것은 컴파일러의 버전, 종류, 옵션에 따라 RVO와 NRVO의 적용 여부가 달라졌기 때문입니다.

     

    위 코드의 Before버전은 RVO가 수행되어 return 문에서 c가 복사되지 않습니다. 물론, 이동 연산도 발생하지 않습니다.

    After는 return문에서 std::move가 수행되어 c가 이동하게 되고, 이 반환은 지역 객체 c가 아닌 c에 대한 참조입니다. 따라서 RVO의 조건 2가 만족되지 못하고 반환값 메모리에 c를 이동시켜야 합니다.

     

    RVO는 필수로 적용되는 기능이 아닌, 하나의 최적화 기법입니다. 따라서 컴파일러는 RVO를 수행하지 않을 수도 있습니다.

    하지만, 위와 같은 경우에도 std::move는 적용하지 않는 것이 좋습니다.

    표준에 따르면 RVO의 필수 조건이 충족되었지만 컴파일러가 이를 수행하지 않을 경우, 반환 객체는 R-value로 취급되어야 한다고 합니다. 따라서, RVO가 수행되지 않을 경우, 객체에는 암묵적인 std::move가 적용됩니다.

     

    결론적으로 위 예제 함수에 대하여 컴파일러가 RVO를 수행할 수도, 수행하지 않을 수도 있지만 std::move는 사용하지 않는 것이 좋습니다.

     


     

    std::move와 std::forward를 사용하는 상황, 혹은 사용하는 것이 좋지 않은 상황을 살펴보았습니다.

    요약하면 R-value에는 std::move를, 보편참조에는 std::forward를 사용하는 것이 좋습니다.

    추가로 두 함수를 사용하지 않는 것이 권장되는 경우도 살펴보았습니다.

    RVO가 적용될 수 있는 함수에는 std:::move와 std::forward를 사용하지 않는 것이 좋습니다.

     

    감사합니다.

    댓글

Designed by Tistory.