2020년 6월 4일 목요일

C++ class 선언 시 소멸자, 복사 생성자(copy constructor), 이동 생성자(move constructor) 처리, The rule of three/five/zero

C++에서는 기본적인 내용인데 간과하기 쉬운게 class 생성시 member 변수의 형식에 따라서 생성자, 소멸자, 연산자를 처리해줘야 한다. 기본적인 생성자, 소멸자, 복사 생성자/할당 연산자, 이동 생성자/할당 연산자에 대해서는 아래 MSDN을 참고하면 자세하고 친절히 설명해준다.

- 생성자(constrctor), 소멸자(destructor)

- 복사 생성자(copy constructor), 복사 할당 연산자(copy assign operator)

- 이동 생성자(move constructor), 이동 할당 연산자(move assignment operator)


예를 들어 class member 변수가 pointer와 같이 복사, 이동 시 관리되지 않는 변수 형식이라면 class가 생성, 소멸, 복사, 이동 시 해당 member 변수가 정상적으로 할당, 삭제, 복사, 이동 될 수 있도록 해줘야 한다.

왜나면 C++에서는 특정 조건에 따라 컴파일러가 class의 생성자, 복사 생성자, 복사 할당 연산자, 소멸자를 자동으로 생성하게 되어 의도치 않게 member 변수가 복사, 이동 될 수 있음.

"C++ 얕은복사 vs 깊은복사" 검색어나 "C++ shallow copy vs deep copy" 검색어로 검색하면 관련 글들이 꽤 나온다.


그래서 C++ class 선언과 관련된 The rule of three/five/zero 같은 용어가 있음.

간단히 말하면 class 선언 시 member 변수의 처리에 따라 3개, 5개, 0개의 생성자, 연산자를 정의하라는 것이고 간략히 내용을 발췌하면 다음과 같음.

아래 Rule of three 코드에서 복사 시 제대로 관리되지 않는 char* 형의 cstring member 변수가 있다.
만약 복사 생성자를 정의하지 않아 컴파일러가 복사 생성자를 만든다면 해당 member 변수는 복사 시 얕은 복사(shallow copy)가 되어 member 변수의 pointer 주소만 복사되어 두 class instance는 동일한 메모리를 바라보게 된다. 프로그램 실행 중 한 instance가 소멸되면 다른 instance의 변수가 영향을 받는다.

그래서 아래 코드에서는 3개(복사 생성자(copy constructor), 복사 할당 연산자(copy assignment), 소멸자) member 함수를 정의 하여 깊은 복사(deep copy)를 하여 복사된 class instance가 복사한 class instance와 다른 저장공간의 member 변수를 가지도록 한다.

일반적으로 관리되지 않는 member 변수 특히나 char* 형식의 단순 문자열을 가지고 있는 경우 아래와 같이 정의하여 처리한다.

Rule of three

class rule_of_three
{
    char* cstring; // raw pointer used as a handle to a dynamically-allocated memory block
 
    void init(const char* s)
    {
        std::size_t n = std::strlen(s) + 1;
        cstring = new char[n];
        std::memcpy(cstring, s, n); // populate
    }
 public:
    rule_of_three(const char* s = "") { init(s); }
 
    ~rule_of_three()
    {
        delete[] cstring;  // deallocate
    }
 
    rule_of_three(const rule_of_three& other) // copy constructor
    { 
        init(other.cstring);
    }
 
    rule_of_three& operator=(const rule_of_three& other) // copy assignment
    {
        if(this != &other) {
            delete[] cstring;  // deallocate
            init(other.cstring);
        }
        return *this;
    }
};


아래 코드에서는 동일한 상황이지만 class instance가 이동 생성자(move constructor), 이동 할당 연산자(move assignment operator)가 사용되는 상황을 처리하기 위해 5개(이동 생성자(move constructor), 이동 할당 연산자(move assignment operator), 복사 생성자(copy constructor), 복사 할당 연산자(copy assignment), 소멸자) member 함수를 정의하였음.

예제의 swap 함수를 보면 알겠지만 할당된 메모리양이 많은 char* member 변수를 가지고 있는 경우 class instance 이동 시 performance를 고려하고자 이동 생성자, 할당 연산자를 정의하는 경우가 있다고 함.

Rule of five

class rule_of_five
{
    char* cstring; // raw pointer used as a handle to a dynamically-allocated memory block
 public:
    rule_of_five(const char* s = "")
    : cstring(nullptr)
    { 
        if (s) {
            std::size_t n = std::strlen(s) + 1;
            cstring = new char[n];      // allocate
            std::memcpy(cstring, s, n); // populate 
        } 
    }
 
    ~rule_of_five()
    {
        delete[] cstring;  // deallocate
    }
 
    rule_of_five(const rule_of_five& other) // copy constructor
    : rule_of_five(other.cstring)
    {}
 
    rule_of_five(rule_of_five&& other) noexcept // move constructor
    : cstring(std::exchange(other.cstring, nullptr))
    {}
 
    rule_of_five& operator=(const rule_of_five& other) // copy assignment
    {
         return *this = rule_of_five(other);
    }
 
    rule_of_five& operator=(rule_of_five&& other) noexcept // move assignment
    {
        std::swap(cstring, other.cstring);
        return *this;
    }
 
// alternatively, replace both assignment operators with 
//  rule_of_five& operator=(rule_of_five other) noexcept
//  {
//      std::swap(cstring, other.cstring);
//      return *this;
//  }
};

class의 member 변수가 복사, 이동 시 관리되는 경우라면 컴파일러가 생성하는 default 생성자, 연산자를 사용하도록 지정한 예제 코드들이다. 처음 코드 처럼 아무것도 선언하지 않거나 두번째 코드 처럼 default로 지정해도 class 생성, 복사, 이동 시 처리되는 결과는 동일한다.

C++로 개발하고 파일 operation 처리와 같이 byte stream을 처리해야 하는 경우가 아닌 단순 문자열을 처리하는 상황, performance가 critical한 요소가 아니라면 std::string을 사용하는게 당연해 보인다.

Rule of zero

class rule_of_zero
{
    std::string cppstring;
 public:
    rule_of_zero(const std::string& arg) : cppstring(arg) {}
};

class base_of_five_defaults
{
 public:
    base_of_five_defaults(const base_of_five_defaults&) = default;
    base_of_five_defaults(base_of_five_defaults&&) = default;
    base_of_five_defaults& operator=(const base_of_five_defaults&) = default;
    base_of_five_defaults& operator=(base_of_five_defaults&&) = default;
    virtual ~base_of_five_defaults() = default;
};


하지만 컴파일러가 자동으로 생성하는 것을 고려한다면 class 선언이 다음 규칙을 따르도록 해야 한다. 괜히 어설프가 일부 생성자, 연산자를 정의하면 자동으로 생성되지 않을 수 있어 곤란해질 수 있다.


 따라서 단순 형식에는 편리하지만 복합 형식은 종종 하나 이상의 특수 멤버 함수 자체를 정의하므로 다른 특수 멤버 함수가 자동으로 생성되지 않도록 할 수 있습니다. 실제로는 다음과 같습니다.
  • 생성자가 명시적으로 선언된 경우 기본 생성자가 자동으로 생성되지 않습니다.

  • 가상 소멸자가 명시적으로 선언된 경우 기본 소멸자가 자동으로 생성되지 않습니다.

  • 이동 생성자 혹은 이동 할당 연산자가 명시적으로 선언된 경우 다음과 같습니다.

    • 복사 생성자가 자동으로 생성되지 않습니다.

    • 복사 할당 연산자가 자동으로 생성되지 않습니다.

  • 복사 생성자, 복사 할당 연산자, 이동 생성자, 이동 할당 연산자 또는 소멸자가 명시적으로 선언된 경우 다음과 같습니다.

    • 이동 생성자가 자동으로 생성되지 않습니다.

    • 이동 할당 연산자가 자동으로 생성되지 않습니다.

 참고

또한 C++ 11 표준은 다음 추가 규칙을 지정합니다.

  • 복사 생성자나 소멸자가 명시적으로 선언된 경우 복사 할당 연산자가 자동으로 생성되지 않습니다.
  • 복사 할당 연산자나 소멸자가 명시적으로 선언된 경우 복사 생성자가 자동으로 생성되지 않습니다.

두 경우 모두 Visual Studio에서는 필요한 함수가 암시적으로 자동 생성되며 경고를 생성하지 않습니다.


참고로 아래 rule 같이 하나를 정의할 거면 모두 정의하던지 delete 처리하라던지 조언 하기도 함.

C.21: If you define or =delete any default operation, define or =delete them all

댓글 없음:

댓글 쓰기