ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] 값 전달을 고려할만한 상황
    C++/Effective Modern C++ 2023. 6. 9. 14:04

    값 전달(Pass by value)은 C++에서 기피되는 방식입니다.

    전달 과정에서 이미 복사(이동)연산이 추가적으로 발생하며, 그 외에도 일어날 수 있는 추가적인 문제가 있기 때문입니다.

    이에 권장되는 전달 방식은 참조 전달(Pass by reference)입니다.

     

    하지만, 이번 글에서는 그럼에도 불구하고 값 전달을 고려할 수 있는 경우를 살펴볼 것 입니다.

     


     

    이번 글에서 살펴 볼 예제 코드의 시나리오는 다음과 같습니다.

    MyClass라는 클래스 내의 멤버 함수를 다룹니다.
    이 함수는 문자열을 받아서 자신의 문자열 배열 멤버 변수에 저장합니다.

    위 내용의, 멤버 함수를 제외한 클래스 구현은 다음과 같습니다.

    class MyClass {
    public:
        // Function
    private:
        std::vector<std::string> names;
    };

    본문에서는 멤버 함수를 구현하는 여러가지 방법에 대하여 다루도록 하겠습니다.

     

    1. Pass by reference

     

    더보기

    참조 전달을 통해 구현한 함수는 다음과 같을 수 있습니다.

    // L-value reference
    void addName(const std::string& newName) {
        names.push_back(newName); 
    }
    // R-value reference
    void addName(std::string&& newName) {
        names.push_back(std::move(newName)); 
    }

    L,R value에 대한 함수의 구현이 다릅니다.

    이로 인해 한 기능을 하는 함수가 두 개가 되었습니다.

    함수가 두 개가 되었다는 것은, 문서화, 유지보수, 프로그램의 크기도 그만큼 증가한다는 것 입니다.

     

    비용 측면에서는 다음과 같습니다.

    전달 : 참조 전달이기 때문에 비용이 없습니다.

    본문 : names에서 각각 복사(L-value)1회, 이동(R-value)1회가 발생합니다.

    종합 : L-value일 경우 복사 1회, R-vlaue일 경우 이동 1회

     

    2. Universal reference

     

    더보기

    보편 참조를 이용한 구현은 다음과 같습니다.

    template<typename T>
    void addName(T&& newName) {
        names.push_back(std::forward<T>(newName));
    }

    이 함수는 위 문단의 참조 구현과 비교하여, 구현할 함수가 하나로 줄었습니다.

    하지만, 그와는 별개로 다른 문제가 발생할 수 있습니다.

    우선, 템플릿을 이용한 구현이기 때문에, std::string으로 변환될 수 있는 여러 값에 대하여 인스턴스화 될 가능성이 있습니다.

    또한, 위 경우 (여러 형식에 대한 인스턴스화)에 겹쳐서, std::forward가 실패하는 경우가 있습니다.

    이에 대해서는 다른 글 (완벽 전달의 실패)에서 다루었습니다.

     

    비용 측면에서는 다음과 같습니다.

    전달 : 참조 전달이기 때문에 비용이 없습니다.

    본문 : names에서 각각 복사(L-value)1회, 이동(R-value)1회가 발생합니다.

    종합 : L-value일 경우 복사 1회, R-vlaue일 경우 이동 1회

     

    다만, 이 구현은 템플릿이기 때문에, 위 통계가 정확하지 않을 수 있습니다.

    예를 들어, std::string이외의 매개변수에 의한 인스턴스는 std::string의 생성으로 인한 복사, 이동 연산이 발생할 수 있습니다.

     

    3. Pass by value

     

    더보기

    값 전달 방식의 구현은 다음과 같습니다.

    void addName(std::string newName) {
        names.push_back(std::move(newName));
    }

    해당 코드의 본문에 대하여, std::move를 사용한 이유는 다음과 같습니다.

    1. 호출자가 전달한 객체와 newName은 서로 독립적인 객체입니다. 따라서 본문의 행동이 호출자에게 영향을 미치지 않습니다.
    2. push_back이 newName의 마지막 사용입니다. 객체를 이동하더라도, 함수의 다른 부분에 영향을 미치지 않습니다.

     

    비용 측면에서는 다음과 같습니다.

    전달 : 전달된 값에 대하여, newName의 생성자는 무조건 호출됩니다. 하지만, L-value에 대하여 복사 생성되며, R-value에 대하여 이동 생성된다는 차이가 존재합니다.

    본문 : 모든 경우에서 newName의 이동 연산이 1회 발생합니다.

    종합 : L-value일 경우 복사 1회 + 이동 1회, R-vlaue일 경우 이동 2회

     

    4. Non-copyable type

     

    더보기

    값 전달 방식에 대하여, 복사가 불가능한 형식에 대해 추가로 살펴보도록 하겠습니다.

    복사 불가능한 형식의 예시는 std::unique_ptr이 있습니다.

     

    이 경우에 대한 코드를 우선적으로 살펴보면 다음과 같습니다.

    우선, 참조 전달 방식입니다.

    class MyClass2 {
    public:
        void setPtr(std::unique_ptr<std::string>&& ptr) {
            p = std::move(ptr);
        }
    private:
        std::unique_ptr<std::string> p;
    };

    복사 불가능한 형식은 L-vlaue에 대한 참조가 불필요하기 때문에, 한 가지 함수로 충분합니다.

    비용은 이동 1회입니다. (p에 대한 이동 배정)

     

    다음은 값 전달 방식입니다.

    class MyClass2 {
    public:
        void setPtr(std::unique_ptr<std::string> ptr) {
            p = std::move(ptr);
        }
    private:
        std::unique_ptr<std::string> p;
    };

    비용은 이동 2회입니다. (ptr에 대한 이동 생성, p에 대한 이동 배정)

     

    복사 불가능한 형식의 경우, 위 문단들에서 살펴본 경우처럼 이동 연산이 한번 더 발생합니다.

    하지만 위 문단들과의 결정적인 차이점으로, 참조 전달 방식에서 문제점으로 짚었던 함수의 갯수 문제가 발생하지 않는다는 것 입니다.

    관리해야 할 함수의 갯수가 하나로 동일한데, 연산의 횟수는 더 많아지므로, 복사 불가능한 객체에 대하여 값 전달 방식은 적합하지 않음이 자명합니다.

     

    5. 그 외

     

    더보기

    그 외에 살펴볼만한 경우는 다음과 같습니다.

    우선, 불필요한 비용이 발생하는 경우입니다.

    void addName(std::string newName) {
        if(newName.length() > 10)
            names.push_back(std::move(newName));
    }

    위 함수는 names에 추가되는 newName에 제약을 건 함수입니다.

    이 경우, 만약 추가되지 않을 newName이 생성될 경우, 참조 전달에서는 아예 발생하지 않는 불필요한 생성, 소멸 비용이 발생합니다.

     

    다음은 배정 연산입니다.

    class MyClass3 {
    public:
        // Constructor : text assignment
        explicit MyClass3(std::string str) : text(std::move(str)) {}
        
        // Function : text assignment
        void changeText(std::string str) {
            text = std::move(str);
        }
    private:
        std::string text;
    };

    생성과 배정은 별개입니다.

    생성을 통한 전달은 위 문단에서 살펴본 것과 같이 1회의 이동이 추가됩니다.

    하지만 배정의 경우, 위 MyClass3의 코드를 사용하는 다음 코드를 살펴보도록 하겠습니다.

    // Init MyClass3
    std::string init("Supercalifragilisticexpialidocious");
    MyClass3 cls(init);
    
    // Change text
    std::string newStr("Newstring");
    cls.changeText(newStr);

    위와 같은 코드를 사용할 경우, 이동, 복사 연산 외에 메모리 할당과 해제 연산이 일어납니다.

    이는 이동 연산과는 비교할 수 없을만큼 큰 연산이기 때문에, 값 전달이 적합하지 않습니다.

     

    다음은 Slicing problem에 관한 문제입니다.

    이것은 상위 클래스의 값을 받는 함수에 하위 클래스의 변수를 전달할 때, 정보가 잘려나가는 문제입니다.

    // 상위 클래스
    class Ancestor { ... };
    
    // 하위 클래스
    class Descendant : public Ancestor { ... };
    
    void Function(Ancestor cls);
    
    ...
    
    Descendant d;
    Function(d); // Problem

    위와 같은 상황에서, Function에 전달된 cls는 Descendant형식인 d의 정보가 잘려나가 있습니다.

    이 문제는 하위 클래스의 형식으로 변경하거나, 참조로 해결할 수 있는 문제로, 값 전달이 적합하지 않은 예시 중 하나로 자주 쓰이곤 합니다.

     


     

    참조, 보편참조, 값 전달 방식의 간단한 구현과, 비용에 대해 살펴보았습니다.

    그 외에도, 3가지 경우 외에 특별한 경우 (복사 불가능 형식, 메모리 할당 문제, Slicing problem)또한 살펴보았습니다.

    특별한 문제의 경우, 내용 자체는 특별하다기 보단 자주 발생하는 이슈입니다.

    따라서 값 전달 방식은 상황에 따라 유용하게 쓰일 수 있지만, 쉽게 생각해서는 안될 방법 정도로 판단하는 것이 좋을 것 같습니다.

     

    감사합니다.

    댓글

Designed by Tistory.