[Effective Modern C++] Chapter 8. 다듬기 [항목 41~42]

Chapter 8. 다듬기

항목 41. 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라.

왼값 참조와 오른값 참조 버전 2개를 모두 만드는 것은 코드 중복, 유지보수 측면에서 단점이고, 보편 참조 전달 버전은 항목 26/27/30 에서 말했던 것들과 같은 문제들이 발생할 수 있다. 따라서 약간의 효율성을 포기하면 이러한 단점들을 피할 수 있는데, 그것이 바로 값 전달을 활용하는 것이다. (효율성 : 보편 참조 버전 >= 왼값 참조 버전 + 오른값 참조 버전 >= 값 전달 버전)
그러나, 값 전달의 경우에도 주의해야 할 점들이 있다.
1. 잘림 문제 (slicing problem)
2. 값 전달 함수들이 꼬리를 물고 호출되면, 전체적인 성능이 급격히 하락할 수 있다.
3. "값 전달 (복사 생성) -> 이동 배정" 의 경우 "참조 전달 -> 복사 배정" 보다 훨씬 비쌀 가능성이 있다. (예를 들면, std::string이나 std::vector등의 memory allocation 때문에)

흠, 나는 값 전달 버전이 얼마나 유용할 지 의문이 든다. 일단 이동 연산 하나가 불필요하게 낭비된다. 사실 이동이 저렴한 경우에는 유지보수, 코드중복 해결의 장점을 봤을 때 이러한 사소한 낭비를 무시할 수 있다. 그러나 사실 큰 문제는 이동이 저렴하다고 생각했는데 저렴하지 않을 수 도 있다는 것이다. std::string이나 std::vector같은 경우, memory allocation 때문에 값 전달 후 이동 배정을 하는 것이 상당히 느려질 수 있는데, 코드를 짤 때 이러한 점을 간과하거나 실수 할 가능성이 크다. 또한, 잘림 문제도 주의해야 한다. 즉, 값 전달을 사용하는 것은 참조 버전보다 실수할 가능성이 더 크기 때문에 일단 기본적으로는 피해야 한다고 생각한다. 내 생각에는 일단 우선적으로 왼값/오른값 참조 버전을 사용하다가, 성능을 더 강화할 필요가 있을 경우에는 보편 참조 버전으로 바꾸는 것이 옳고, 그리고 왼값/오른값 참조 버전들 끼리의 코드 중복이 심해질 경우에 값 전달 버전 혹은 보편 참조 버전을 고려하는 것이 옳다고 생각한다.

[기억해 둘 사항들]
 - 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달이 참조 전달만큼이나 효율적이고, 구현하기가 더 쉽고, 산출되는 목적 코드의 크기도 더 작다.
 - 왼값 인수의 경우 값 전달(즉, 복사 생성) 다음의 이동 배정은 참조 전달 다음의 복사 배정보다 훨씬 비쌀 가능성이 있다.
 - 값 전달에서는 잘림 문제가 발생할 수 있으므로, 일반적으로 기반 클래스 매개변수 형식에 대해서는 값 전달이 적합하지 않다.

항목 42. 삽입 대신 생성 삽입을 고려하라.

얼핏 생각하면 무조건 생성 삽입을 사용하는 것이 옳다고 생각할 수 있다. 나도 그래서 옛날에는 무조건 생성 삽입만을 사용하였다. 그러나 실제적으로 그냥 삽입이 생성 삽입보다 빠를 수도 있고, 삽입과 생성 삽입의 본질적인 차이를 생각해보면 '무조건'은 아니라는 생각이 들 것이다.
#include <set>
#include <iostream>

class Example
{
public:
    explicit Example(int a, int b)
        : m_a(a), m_b(b)
    {
        std::cout << "Example(int, int) called. \n";
    }
    ~Example()
    {
        std::cout << "~Example() called. \n";
    }
    Example(const Example& other)
        : m_a(other.m_a), m_b(other.m_b)
    {
        std::cout << "Example(const Example&) called. \n";
    }
    Example(Example&& other)
        : m_a(other.m_a), m_b(other.m_b)
    {
        std::cout << "Example(Example&&) called. \n";
    }
    Example& operator=(const Example& other)
    {
        std::cout << "operator=(const Example&) called. \n";
        m_a = other.m_a;
        m_b = other.m_b;
        return *this;
    }
    Example& operator=(Example&& other)
    {
        std::cout << "operator=(Example&&) called. \n";
        m_a = other.m_a;
        m_b = other.m_b;
        return *this;
    }

    bool operator<(const Example& other) const { return m_a + m_b < other.m_a + m_b; }

private:
    int m_a, m_b;
};

int main()
{
    std::set<Example> s;
    Example ex(11, 22);
    s.insert(ex);

    Example ex1(33, 44), ex2(55, 66);
    std::cout << "\n";
    std::cout << "-- insert when not duplicated --\n";
    s.insert(ex1);
    std::cout << "--------------------------------\n\n";
    std::cout << "-- emplace when not duplicated --\n";
    s.emplace(ex2);
    std::cout << "---------------------------------\n\n";

    std::cout << "-- insert when duplicated --\n";
    s.insert(ex);
    std::cout << "----------------------------\n\n";
    std::cout << "-- emplace when duplicated --\n";
    s.emplace(ex);
    std::cout << "-----------------------------\n\n";

    /* Execution Result
        Example(int, int) called.
        Example(const Example&) called.
        Example(int, int) called.
        Example(int, int) called.

        -- insert when not duplicated --
        Example(const Example&) called.
        --------------------------------

        -- emplace when not duplicated --
        Example(const Example&) called.
        ---------------------------------

        -- insert when duplicated --
        ----------------------------

        -- emplace when duplicated --
        Example(const Example&) called.
        ~Example() called.
        -----------------------------

        ~Example() called.
        ~Example() called.
        ~Example() called.
        ~Example() called.
        ~Example() called.
        ~Example() called.
    */
}

    위 코드를 보면 알 수 있듯이, std::set과 같이 값의 중복이 금지된 컨테이너의 경우, 생성 삽입이 그냥 삽입보다 더 느릴 수도 있다. 왜냐하면, 생성 삽입의 경우 내부적으로 중복 체크를 위해서 임시 객체를 생성하는 반면에, 그냥 삽입은 참조로서 넘어온 객체를 중복 체크를 위해 사용하기 때문이다. 따라서, 내부적으로 중복 체크를 하는 컨테이너의 경우, 무작정 생성 삽입을 사용하다가는 성능이 더 하락할 수 있음을 명심해야 한다.
    그리고 그냥 삽입과 생성 삽입 사이의 근본적인 차이에서 오는 유의점들이 있다. 첫째로, 그냥 삽입은 복사 초기화를 사용하므로 explicit 생성자를 사용 불가능한 반면, 생성 삽입은 내부적으로 직접 초기화를 사용해서 explicit 생성자를 호출할 수 있다. 따라서 그냥 삽입에서는 compiler error가 나는 것이, 생성 삽입에서는 정상적으로 컴파일될 수 있다. 두 번째로, 생성 삽입은 객체의 생성이 컨테이너 내부의 메모리까지 지연되므로, 예외 안정성 측면에서 문제가 생길 수 있다. 그냥 삽입의 경우, 객체가 바깥에서 생성이 된 뒤 컨테이너 내부에 복사되므로, 컨테이너 내부에 복사하는 과정에서 메모리 부족등의 예외가 발생해도 객체는 정상적으로 소멸된다. 그러나, 생성 삽입의 경우, 객체의 생성이 지연되므로, 컨테이너 내부에서 객체 생성 전에 예외가 발생하면, 객체 생성자의 인자들에 대한 참조를 잃게 된다. 즉, 만약 전달된 인자들 중에 new int 와 같은 것들이 있었다면, memory leak이 발생하는 것이다. 이러한 예외 안정성의 차이는 그냥 삽입은 컨테이너 외부에서 객체가 완전히 생성된 채로 들어오고, 생성 삽입은 컨테이너 내부에서 객체가 생성된다는 근본적인 차이에서 비롯되는 것이다. 따라서, 생성 삽입을 사용할 때는 예외 안정성에 대한 측면도 점검해야 할 필요가 있다. (물론, 사실 new int 와 같은 것들을 직접적으로 생성자의 인자로서 직접적으로 전달하려고 한 것 자체가 잘못이긴 하다.)
    이렇듯, '생성 삽입' 이 '삽입' 보다 항상 우월한 것은 아니다. 그러나, 위에서 언급한 몇 가지 경우만 제외하면 '생성 삽입'이 '삽입'보다 나쁠 이유는 없다. 따라서 일단 기본적으로 '생성 삽입'을 사용하는 것을 원칙으로 하되, 내부적으로 중복 체크를 수행하는 컨테이너들에 대해서는 일반 '삽입'을 사용하는 것이 옳다. 그리고 explicit 생성자 관련 부분과 예외 안정성 부분의 문제는 근본적으로 생성 삽입보다 다른 곳에 근본적인 문제점이 존재하는 것이라고 생각한다. 따라서 생성 삽입을 사용하면서 다른 방법으로 해결 가능하기 때문에 이런 것들은 그냥 조심해서 사용하면 된다고 생각한다. (아..C++은 코드 한줄 한줄 신경쓸께 너무 많아서 짜증난다..)

[기억해 둘 사항들]
 - 이론적으로, 생성 삽입 함수들은 종종 해당 삽입 버전보다 더 효율적이어야 하며, 덜 효율적인 경우는 절대로 없어야 한다.
 - 실질적으로, 만일 (1) 추가하는 값이 컨테이너로 배정되는 것이 아니라 생성되고, (2) 인수 형식(들)이 컨테이너가 담는 형식과 다르고, (3) 그 값이 중복된 값이어도 컨테이너가 거부하지 않는다면, 생성 삽입 함수가 삽입 함수보다 빠를 가능성이 아주 크다.
 - 생성 삽입 함수는 삽입 함수라면 거부당했을 형식 변환들을 수행할 수 있다.



하... 드디어 Modern Effective C++ 포스팅이 끝났다!
작년 이 맘쯤에 Effective C++을 포스팅했었는데, 일 년 후 Modern Effective C++을 포스팅하게 되니 감회가 새롭다.
포스팅을 하면서 책을 다시 한번 더 읽고, 또 추가적인 자료도 찾아보고, 연구도 하고, 생각도 하고,,, 포스팅에 책 외의 내용과 통찰을 추가로 담으려고 하니 포스팅이 너무 오래 걸렸던 것 같다.. 그래도 포스팅을 하려면, 좀 더 완벽하게 modern c++의 내용들을 이해하고 의문점들을 해결해야만 했어서 C++을 좀 더 꼼꼼히 살펴볼 수 있던 계기가 된 것 같다. 그렇지만 이제 책 포스팅은 왠만하면 안 하려고 한다.. 시간이 너무 오래 걸려서...

댓글